-
+
+
Esc 取消 · Ctrl + Enter 确认 插入公式
+
+
+
+
+
+
编辑引用
+
+
从项目中选择
+
+
名称
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.js b/main.js
index 53812cb..a195611 100644
--- a/main.js
+++ b/main.js
@@ -24,6 +24,12 @@ document.addEventListener('DOMContentLoaded', function() {
previewArea = document.getElementById('previewArea');
statusText = document.getElementById('statusText');
+ // 完整工作区使用独立的多文件状态;单文件核心仍可单独加载和测试。
+ if (typeof initWorkspace === 'function') {
+ initWorkspace();
+ return;
+ }
+
// 绑定工具栏按钮事件
initToolbar();
@@ -224,13 +230,14 @@ function insertBlockFormula() {
}
// 插入图片
-function insertImage() {
- saveEditorSelection();
- const imagePath = prompt('请输入图片路径(LaTeX 中使用的路径):', 'example-image.png');
+function insertImage(options) {
+ const supplied = options && typeof options.imagePath === 'string';
+ if (!supplied) saveEditorSelection();
+ const imagePath = supplied ? options.imagePath : prompt('请输入图片路径(LaTeX 中使用的路径):', 'example-image.png');
if (!imagePath) return;
- const caption = prompt('请输入图片标题:', '图片标题');
+ const caption = supplied ? options.caption : prompt('请输入图片标题:', '图片标题');
if (caption === null) return;
- const label = prompt('请输入图片标签(用于引用):', 'fig:example');
+ const label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example');
if (label === null) return;
const img = document.createElement('img');
@@ -740,9 +747,18 @@ function parseTexToHtml(tex) {
const env = readTexEnvironment(tex, i);
if (env) {
let visual = null;
+ const source = tex.slice(i, env.end);
+ if (env.complete && /^(?:equation|align|gather)\*?$/.test(env.name)) {
+ // Keep labels, tags, row breaks, comments and nested math in the
+ // semantic body. The visual editor must preserve this metadata.
+ visual = texSourceElement('div', source, escapeForHtml(source),
+ ' class="formula-block" data-delimiter="environment" data-environment="' + escapeAttr(env.name) +
+ '" data-formula="' + texSourceAttribute(tex.slice(env.innerStart, env.innerEnd)) +
+ '" data-tex-env-open="' + texSourceAttribute(tex.slice(i, env.innerStart)) +
+ '" data-tex-env-close="' + texSourceAttribute(tex.slice(env.innerEnd, env.end)) + '" contenteditable="false"');
+ }
if (env.complete && (env.name === 'itemize' || env.name === 'enumerate')) visual = parseSimpleTexList(tex, env);
if (env.complete && env.name === 'figure') visual = parseSimpleTexFigure(tex, env);
- const source = tex.slice(i, env.end);
html += visual || texSourceElement('div', source, escapeForHtml(source), ' class="raw-tex"');
i = env.end;
continue;
@@ -832,19 +848,42 @@ function hasTexPackage(preamble, packageName) {
}
// 同时检查内容和根节点属性:居中、标题级别或公式属性变化也必须使原文缓存失效。
-function getTexNodeSnapshot(node) {
+function getTexNodeSnapshot(node, legacyPresentation = false) {
function describe(current) {
if (current.nodeType !== Node.ELEMENT_NODE) return [current.nodeType, current.textContent];
+ // Atomic TeX nodes have editable semantics separate from their rendered
+ // presentation. Decorations must not invalidate an ancestor's source,
+ // while changing a command or formula must invalidate that ancestor.
+ if (!legacyPresentation && current.classList.contains('tex-cmd')) {
+ const source = current.getAttribute('data-tex-source');
+ return ['tex-cmd', source === null ? current.textContent : source];
+ }
+ if (!legacyPresentation && (current.classList.contains('formula-inline') || current.classList.contains('formula-block'))) {
+ return ['formula', current.classList.contains('formula-block') ? 'block' : 'inline',
+ current.getAttribute('data-formula'), current.getAttribute('data-delimiter'),
+ current.getAttribute('data-environment'), current.getAttribute('data-tex-env-open'),
+ current.getAttribute('data-tex-env-close')];
+ }
const attributes = Array.from(current.attributes || [])
.filter(function(attr) { return !attr.name.startsWith('data-tex-'); })
.map(function(attr) { return [attr.name, attr.value]; })
.sort(function(a, b) { return a[0].localeCompare(b[0]); });
return [current.tagName.toLowerCase(), attributes, Array.from(current.childNodes).map(describe)];
}
- // 不把子节点的原文和快照再次嵌进父快照,防止嵌套格式造成元数据膨胀。
+ // Ordinary containers exclude cached source/snapshots to avoid metadata growth.
return JSON.stringify(describe(node));
}
+function upgradeTexNodeSnapshots(root) {
+ for (const node of Array.from(root.querySelectorAll('[data-tex-snapshot]'))) {
+ // Only migrate a still-valid legacy cache. Replacing stale snapshots
+ // would discard edits by making the original source authoritative again.
+ if (node.getAttribute('data-tex-snapshot') === getTexNodeSnapshot(node, true)) {
+ node.setAttribute('data-tex-snapshot', getTexNodeSnapshot(node));
+ }
+ }
+}
+
// 浏览器按 Enter 拆分段落时,不一定为新兄弟节点插入源文件的空白占位符。
function convertEditorNodesToLatex(nodes) {
function ordinaryParagraph(node) {
@@ -900,11 +939,22 @@ function convertNodeToLatex(node) {
const separator = source !== null ? '' : '\n\n';
if (node.classList.contains('tex-whitespace')) return source || '';
if (node.classList.contains('raw-tex')) return getRawTexContent(node);
- if (node.classList.contains('tex-cmd')) return node.textContent;
+ if (node.classList.contains('tex-cmd')) return source === null ? node.textContent : source;
if (node.classList.contains('formula-inline') || node.classList.contains('formula-block')) {
const formula = node.getAttribute('data-formula') || '';
const delimiter = node.getAttribute('data-delimiter');
+ const environment = node.getAttribute('data-environment');
+ if (delimiter === 'environment' && /^(?:equation|align|gather)\*?$/.test(environment || '')) {
+ const escapedName = environment.replace('*', '\\*');
+ const originalOpen = node.getAttribute('data-tex-env-open') || '';
+ const originalClose = node.getAttribute('data-tex-env-close') || '';
+ const open = new RegExp('^\\\\begin[ \\t\\r\\n]*\\{' + escapedName + '\\}$').test(originalOpen)
+ ? originalOpen : '\\begin{' + environment + '}';
+ const close = new RegExp('^\\\\end[ \\t\\r\\n]*\\{' + escapedName + '\\}$').test(originalClose)
+ ? originalClose : '\\end{' + environment + '}';
+ return open + formula + close;
+ }
if (delimiter === '$$') return '$$' + formula + '$$';
if (delimiter === '\\[') return '\\[' + formula + '\\]';
if (delimiter === '\\(') return '\\(' + formula + '\\)';
@@ -1082,9 +1132,9 @@ function sanitizeDraftHtml(html) {
template.innerHTML = html;
const allowedTags = new Set(['P', 'DIV', 'SPAN', 'H1', 'H2', 'H3', 'UL', 'OL', 'LI', 'B', 'STRONG', 'I', 'EM', 'BR', 'IMG']);
const allowedAttributes = new Set(['class', 'contenteditable', 'hidden', 'src', 'alt', 'align', 'title',
- 'data-caption', 'data-label', 'data-width', 'data-formula', 'data-delimiter',
+ 'data-caption', 'data-label', 'data-width', 'data-formula', 'data-delimiter', 'data-environment',
'data-placement', 'data-centering', 'data-has-caption', 'data-caption-tex', 'data-label-tex', 'data-item-label',
- 'data-tex-command', 'data-tex-source', 'data-tex-snapshot']);
+ 'data-tex-command', 'data-tex-source', 'data-tex-snapshot', 'data-tex-env-open', 'data-tex-env-close']);
for (const node of Array.from(template.content.querySelectorAll('*'))) {
if (!allowedTags.has(node.tagName)) {
if (/^(SCRIPT|STYLE|IFRAME|OBJECT|EMBED|SVG|MATH|TEMPLATE)$/.test(node.tagName)) node.remove();
@@ -1117,6 +1167,7 @@ function loadDraft() {
const draft = JSON.parse(serialized);
if (draft.version !== 1 || typeof draft.html !== 'string') throw new Error('草稿格式无效');
editorArea.innerHTML = sanitizeDraftHtml(draft.html);
+ upgradeTexNodeSnapshots(editorArea);
texPreamble = typeof draft.preamble === 'string' ? draft.preamble : null;
texTail = typeof draft.tail === 'string' ? draft.tail : '';
texDocumentOpen = typeof draft.open === 'string' ? draft.open : '\\begin{document}';
@@ -1130,6 +1181,7 @@ function loadDraft() {
if (content !== null) {
editorArea.innerHTML = sanitizeDraftHtml(content);
+ upgradeTexNodeSnapshots(editorArea);
texPreamble = localStorage.getItem('visuellatex_preamble') || null;
texTail = localStorage.getItem('visuellatex_tail') || '';
importedFileName = localStorage.getItem('visuellatex_filename') || null;
@@ -1214,13 +1266,18 @@ function openFormulaModal(mode, formulaNode = null) {
const radio = document.querySelector('input[name="formulaMode"][value="' + (mode || 'inline') + '"]');
if (radio) radio.checked = true;
+ document.querySelectorAll('input[name="formulaMode"]').forEach(function(option) {
+ option.disabled = Boolean(editingFormulaNode && editingFormulaNode.getAttribute('data-delimiter') === 'environment');
+ });
modal.classList.add('show');
+ if (typeof VisualMath !== 'undefined' && VisualMath.open(document.getElementById('formulaInput').value, editingFormulaNode)) return;
document.getElementById('formulaInput').focus();
}
// 关闭公式弹窗
function closeFormulaModal() {
+ if (typeof VisualMath !== 'undefined') VisualMath.close();
document.getElementById('formulaModal').classList.remove('show');
editingFormulaNode = null;
document.getElementById('btnInsertFormula').textContent = '插入公式';
@@ -1245,6 +1302,7 @@ function renderSymbolPalette() {
// 向公式输入框光标处插入符号
function insertSymbolIntoTextarea(symbol) {
+ if (typeof VisualMath !== 'undefined' && VisualMath.insert(symbol)) return;
const ta = document.getElementById('formulaInput');
const start = ta.selectionStart;
const end = ta.selectionEnd;
@@ -1255,7 +1313,13 @@ function insertSymbolIntoTextarea(symbol) {
// 从手动输入插入公式
function insertFormulaFromInput() {
- const latex = document.getElementById('formulaInput').value;
+ let latex;
+ try { latex = typeof VisualMath !== 'undefined' ? VisualMath.value() : document.getElementById('formulaInput').value; }
+ catch (problem) {
+ const error = document.getElementById('formulaError');
+ if (error) error.textContent = problem.message;
+ updateStatus(problem.message); return;
+ }
if (!latex.trim()) {
updateStatus('请输入公式内容');
return;
@@ -1274,6 +1338,10 @@ function insertFormulaFromInput() {
const delimiter = old.getAttribute('data-delimiter');
if (mode === oldMode && delimiter) {
replacement.setAttribute('data-delimiter', delimiter);
+ ['data-environment','data-tex-env-open','data-tex-env-close'].forEach(function(name) {
+ const value = old.getAttribute(name);
+ if (value !== null) replacement.setAttribute(name, value);
+ });
const close = delimiter === '\\(' ? '\\)' : delimiter === '\\[' ? '\\]' : delimiter;
replacement.textContent = delimiter + latex + close;
} else if (mode === oldMode && mode === 'block') {
@@ -1282,6 +1350,7 @@ function insertFormulaFromInput() {
}
// 新节点不携带旧源码快照,祖先的快照比较也会检测到此次修改。
old.parentNode.replaceChild(replacement, old);
+ if (typeof renderVisualMath === 'function') renderVisualMath(editorArea);
updatePreview();
if (saveDraft()) updateStatus('公式已更新');
} else {
@@ -1308,6 +1377,7 @@ function insertFormulaNode(latex, type) {
const node = document.createElement('span');
configureFormulaNode(node, latex, type);
if (!insertNodeAtCursor(node)) return;
+ if (typeof renderVisualMath === 'function') renderVisualMath(editorArea);
updatePreview();
if (saveDraft()) updateStatus('公式已插入');
}
@@ -1337,14 +1407,19 @@ function restoreEditorSelection() {
// 初始化公式弹窗
function initFormulaModal() {
renderSymbolPalette();
+ if (typeof VisualMath !== 'undefined') VisualMath.initialize();
- editorArea.addEventListener('dblclick', function(e) {
+ function editFormula(e) {
+ if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target.nodeType === Node.ELEMENT_NODE ? e.target : e.target.parentElement;
const formula = target && target.closest ? target.closest('.formula-inline, .formula-block') : null;
if (!formula || !editorArea.contains(formula)) return;
e.preventDefault();
openFormulaModal(formula.classList.contains('formula-block') ? 'block' : 'inline', formula);
- });
+ }
+ editorArea.addEventListener('click', editFormula);
+ editorArea.addEventListener('dblclick', editFormula);
+ editorArea.addEventListener('keydown', editFormula);
// 关闭按钮
document.getElementById('formulaModalClose').addEventListener('click', closeFormulaModal);
@@ -1353,6 +1428,22 @@ function initFormulaModal() {
document.getElementById('formulaModal').addEventListener('click', function(e) {
if (e.target === this) closeFormulaModal();
});
+ document.getElementById('formulaModal').addEventListener('keydown', function(e) {
+ // Keep document shortcuts out of the formula draft. Ctrl+S commits the
+ // formula itself; native MathLive undo remains inside the mathfield.
+ e.stopPropagation();
+ if (e.key === 'Escape') { e.preventDefault(); closeFormulaModal(); }
+ else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
+ e.preventDefault(); insertFormulaFromInput();
+ } else if (e.key === 'Tab' && this.querySelectorAll) {
+ const fields = Array.from(this.querySelectorAll('button,input,textarea,summary,math-field'))
+ .filter(function(field) { return !field.disabled && field.getClientRects && field.getClientRects().length; });
+ if (!fields.length) return;
+ const first = fields[0], last = fields[fields.length-1];
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+ }
+ });
// 插入按钮
document.getElementById('btnInsertFormula').addEventListener('click', insertFormulaFromInput);
@@ -1361,6 +1452,9 @@ function initFormulaModal() {
document.getElementById('formulaInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
+ // This shortcut confirms the formula locally; it must not also
+ // reach the workspace's compile shortcut and hide the editor.
+ e.stopPropagation();
insertFormulaFromInput();
}
});
diff --git a/pdf-preview.mjs b/pdf-preview.mjs
new file mode 100644
index 0000000..dc73ddd
--- /dev/null
+++ b/pdf-preview.mjs
@@ -0,0 +1,97 @@
+import {lstat, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import {CompilationError, runProcess} from './compiler.mjs';
+
+export const PREVIEW_LIMITS = Object.freeze({maxPages: 100, width: 1200, height: 1600, maxImageBytes: 8 * 1024 * 1024});
+const PNG_HEADER = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+
+function validatePdf(pdf) {
+ if (!Buffer.isBuffer(pdf) || pdf.length > 80 * 1024 * 1024 || pdf.subarray(0, 5).toString() !== '%PDF-') {
+ throw new CompilationError('无法预览无效或过大的 PDF', 'PREVIEW_INVALID_PDF');
+ }
+}
+
+async function withPdf(pdf, options, action) {
+ validatePdf(pdf);
+ const {engine, signal, runner = runProcess, timeoutMs = 20000} = options;
+ if (!engine?.available || !engine.executable) throw new CompilationError('未安装 Ghostscript,仍可导出 PDF', 'PREVIEW_ENGINE_MISSING');
+ if (signal?.aborted) throw new CompilationError('预览已取消', 'PREVIEW_CANCELLED');
+ const temporaryParent = path.resolve(options.temporaryParent || os.tmpdir());
+ const directory = await mkdtemp(path.join(temporaryParent, 'visuallatex-preview-'));
+ const input = path.join(directory, 'document.pdf');
+ const output = path.join(directory, 'page.png');
+ const controller = new AbortController();
+ const cancel = () => controller.abort(new CompilationError('预览已取消', 'PREVIEW_CANCELLED'));
+ signal?.addEventListener('abort', cancel, {once: true});
+ if (signal?.aborted) cancel();
+ const timer = setTimeout(() => controller.abort(new CompilationError('预览生成超时;仍可导出 PDF', 'PREVIEW_TIMEOUT')),
+ Math.max(1, Math.min(timeoutMs, 20000)));
+ const portable = value => value.replace(/\\/g, '/');
+ const env = {...process.env, TMPDIR: directory, TEMP: directory, TMP: directory};
+ delete env.GS_OPTIONS;
+ const run = async args => {
+ if (controller.signal.aborted) throw controller.signal.reason;
+ const result = await runner(engine.executable, ['-q', '-dSAFER', '-dBATCH', '-dNOPAUSE',
+ '--permit-file-read=' + portable(input), '--permit-file-write=' + portable(output), ...args],
+ {cwd: directory, env, signal: controller.signal, input, output});
+ if (controller.signal.aborted) throw controller.signal.reason;
+ if (result.code !== 0) throw new CompilationError('无法生成页面预览;仍可导出 PDF', 'PREVIEW_FAILED', result.log || '');
+ return result;
+ };
+ try {
+ await writeFile(input, pdf, {flag: 'wx'});
+ return await action({input: portable(input), output: portable(output), run});
+ } catch (problem) {
+ if (controller.signal.aborted) throw controller.signal.reason;
+ throw problem;
+ } finally {
+ clearTimeout(timer);
+ signal?.removeEventListener('abort', cancel);
+ if (path.dirname(directory) === temporaryParent && path.basename(directory).startsWith('visuallatex-preview-')) {
+ await rm(directory, {recursive: true, force: true, maxRetries: 3});
+ }
+ }
+}
+
+export async function getPdfPreviewInfo(pdf, options = {}) {
+ if (!options.engine?.available || !options.engine.executable) {
+ return {available: false, reason: '未安装 Ghostscript,无法显示图片预览;仍可导出 PDF。'};
+ }
+ return withPdf(pdf, options, async ({input, run}) => {
+ // Pass the filename as a Ghostscript string option, never interpolate it
+ // into executable PostScript. Only the fixed counting program is run.
+ const result = await run(['-dNODISPLAY', '-sPDFFile=' + input, '-c',
+ 'PDFFile (r) file runpdfbegin (VISUALLATEX_PAGE_COUNT:) print pdfpagecount = runpdfend quit']);
+ const counts = [...String(result.log || '').matchAll(/^VISUALLATEX_PAGE_COUNT:(\d+)\s*$/gm)];
+ const pageCount = Number(counts.at(-1)?.[1]);
+ if (!Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount > 1_000_000) {
+ throw new CompilationError('无法读取 PDF 页数;仍可导出 PDF', 'PREVIEW_INVALID_PAGE_COUNT');
+ }
+ const {maxPages, width, height} = PREVIEW_LIMITS;
+ return {available: true, pageCount, maxPages, width, height};
+ });
+}
+
+export async function renderPdfPreviewPage(pdf, page, options = {}) {
+ const {maxPages, width, height, maxImageBytes} = PREVIEW_LIMITS;
+ if (!Number.isSafeInteger(page) || page < 1 || page > maxPages || !Number.isSafeInteger(options.pageCount) || page > options.pageCount) {
+ throw new CompilationError('预览页码超出范围(最多预览前 100 页)', 'PREVIEW_PAGE_RANGE');
+ }
+ return withPdf(pdf, options, async ({input, output, run}) => {
+ await run(['-sDEVICE=png16m', '-r120', '-g' + width + 'x' + height, '-dFIXEDMEDIA', '-dPDFFitPage',
+ '-dNumRenderingThreads=1', '-dMaxBitmap=20000000', '-dBufferSpace=20000000',
+ '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', '-dFirstPage=' + page, '-dLastPage=' + page,
+ '-sOutputFile=' + output, '-f', input]);
+ const info = await lstat(output);
+ if (!info.isFile() || info.isSymbolicLink() || info.size > maxImageBytes) {
+ throw new CompilationError('预览图超过大小限制', 'PREVIEW_OUTPUT_TOO_LARGE');
+ }
+ const png = await readFile(output);
+ if (png.length < 24 || !png.subarray(0, 8).equals(PNG_HEADER) || png.subarray(12, 16).toString() !== 'IHDR' ||
+ png.readUInt32BE(16) !== width || png.readUInt32BE(20) !== height) {
+ throw new CompilationError('引擎未生成有效尺寸的 PNG 预览', 'PREVIEW_INVALID_IMAGE');
+ }
+ return png;
+ });
+}
diff --git a/preview.css b/preview.css
new file mode 100644
index 0000000..d4f385a
--- /dev/null
+++ b/preview.css
@@ -0,0 +1,52 @@
+/* The PDF proof occupies the right-hand work surface, next to its source. */
+.compile-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; flex: 0 0 auto; padding: 7px 12px; border-bottom: 1px solid var(--border); background: #f7f9fc; }
+.compile-toolbar label, .compiler-status { font-size: 11px; color: var(--text-secondary); }
+.workspace-app .compile-toolbar select { min-width: 130px; max-width: 280px; min-height: 32px; padding: 4px 22px 4px 8px; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); background: white; font: 12px var(--editor-font); }
+.workspace-app .compile-toolbar select:focus-visible { outline: 2px solid #3972bc; outline-offset: 2px; }
+.compiler-status { margin-left: auto; }
+.workspace-app #btnCompilerRetry { min-height: 27px; padding: 3px 6px; border-color: transparent; background: transparent; font-size: 10px; }
+.compilation-visible .workbench.main-content { grid-template-columns: 190px minmax(280px, 1fr) minmax(320px, 44%); position: relative; }
+.compilation-visible .context-sidebar { display: none; }
+.compilation-visible #btnOutline { display: inline-block; }
+.compilation-visible.outline-open .context-sidebar { display: flex; position: absolute; z-index: 25; right: 0; top: 0; bottom: 0; width: 270px; box-shadow: -12px 0 28px #1e334724; }
+.pdf-preview-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-left: 1px solid var(--border-strong); background: #e5eaf1; }
+.pdf-preview-header { display: flex; align-items: center; gap: 10px; min-height: 49px; flex: 0 0 auto; padding: 8px 12px; background: var(--surface-soft); border-bottom: 1px solid var(--border); }
+.pdf-preview-header h2 { font-size: 12px; font-weight: 650; white-space: nowrap; }
+.pdf-preview-header span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 10px var(--editor-font); color: var(--text-secondary); }
+.workspace-app #btnClosePreview { margin-left: auto; min-height: 26px; padding: 2px 7px; border-color: transparent; background: transparent; font-size: 11px; }
+.compile-status { margin: 0; padding: 9px 12px; flex: 0 0 auto; font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; background: #f6f8fc; color: var(--text-secondary); }
+.compile-status[data-state="failed"], .compile-status[data-state="unavailable"] { color: #91432c; background: #fff5eb; }
+.compile-status[data-state="running"] { color: var(--accent); background: var(--accent-soft); }
+.preview-stale { margin: 0; padding: 7px 12px; font-size: 11px; line-height: 1.7; background: #fff3d8; color: #795719; }
+.pdf-proof { flex: 1; display: flex; min-height: 100px; position: relative; }
+.pdf-proof iframe { width: 100%; flex: 1; border: 0; background: #dde3ec; }
+.preview-page-controls { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; gap: 12px; padding: 7px 10px; background: #f7f9fc; border-bottom: 1px solid var(--border); }
+.preview-page-controls span { color: var(--text-secondary); font: 11px var(--editor-font); }
+.preview-page-controls button { min-height: 28px; padding: 4px 9px; font-size: 11px; }
+.page-preview-status { flex: 0 0 auto; margin: 0; padding: 7px 12px; color: var(--text-secondary); background: #f4f7fb; font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; }
+.workspace-app .retry-preview { flex: 0 0 auto; margin: 0; min-height: 30px; border: none; border-radius: 0; background: #f4f7fb; color: var(--accent); font-size: 11px; text-decoration: underline; }
+.pdf-page-view { flex: 1; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
+.pdf-page-view img { display: block; width: 100%; height: auto; margin: 0 auto; background: white; box-shadow: 0 2px 10px #26395420; }
+.pdf-empty { align-self: center; margin: auto; max-width: 280px; padding: 32px 20px; color: var(--text-secondary); text-align: center; font-size: 12px; line-height: 1.9; }
+.pdf-empty strong { display: block; margin-bottom: 7px; color: var(--text); font-size: 15px; font-weight: 500; }
+.pdf-open { display: block; flex: 0 0 auto; padding: 6px 12px; background: var(--surface-soft); color: var(--accent); font-size: 10px; text-decoration: underline; }
+.compile-logs { flex: 0 0 auto; max-height: 35%; overflow: auto; border-top: 1px solid var(--border-strong); background: #f5f7fb; }
+.compile-logs summary { cursor: pointer; padding: 9px 12px; color: var(--text); font-size: 11px; }
+.compile-logs pre { margin: 0; padding: 0 12px 12px; font: 10px/1.6 var(--editor-font); color: #36455b; white-space: pre-wrap; overflow-wrap: anywhere; }
+@media (max-width: 1100px) {
+ .compilation-visible .workbench.main-content { grid-template-columns: minmax(260px, 1fr) minmax(320px, 49%); }
+ .compilation-visible .project-sidebar { display: none; }
+ .compilation-visible #btnFiles { display: inline-flex; }
+ .compilation-visible.files-open .project-sidebar { display: flex; position: absolute; z-index: 26; left: 0; top: 0; bottom: 0; width: 270px; box-shadow: 12px 0 28px #1e334724; }
+}
+@media (max-width: 700px) {
+ .compile-toolbar { padding: 7px 8px; gap: 6px; }
+ .workspace-app .compile-toolbar select { min-width: 0; max-width: none; flex: 1; min-height: 37px; font-size: 14px; }
+ .compile-toolbar label { display: none; }
+ .compile-toolbar button { min-height: 37px; }
+ .compiler-status { width: auto; margin-left: 0; }
+ .compilation-visible .workbench.main-content { grid-template-columns: minmax(0, 1fr); }
+ .compilation-visible .document-pane { display: none; }
+ .pdf-preview-pane { border-left: 0; }
+ .workspace-app #btnClosePreview { min-height: 36px; }
+}
diff --git a/preview.js b/preview.js
new file mode 100644
index 0000000..3d5b4c8
--- /dev/null
+++ b/preview.js
@@ -0,0 +1,456 @@
+/* Local compiler client. Source stays in ProjectModel; PDF results are disposable. */
+(function (root) {
+ 'use strict';
+
+ function kindForFile(file) {
+ if (!file) return null;
+ if (/\.(ps|eps)$/i.test(file.path)) return 'postscript';
+ if (file.kind === 'text' && /\.(tex|latex|ltx)$/i.test(file.path)) return 'latex';
+ return null;
+ }
+ function requestKey(request) {
+ return JSON.stringify({kind: request.kind, mainPath: request.mainPath, files: request.project.files});
+ }
+ function delay(milliseconds, signal) {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) { reject(new Error('已取消')); return; }
+ const abort = () => { clearTimeout(timer); reject(new Error('已取消')); };
+ const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, milliseconds);
+ signal.addEventListener('abort', abort, {once: true});
+ });
+ }
+
+ class CompilationController {
+ constructor({getRequest, fetch: fetcher = root.fetch.bind(root), urls = root.URL,
+ sleep = delay, onChange = () => {}, download = () => {}, protocol = root.location?.protocol,
+ pollInterval = 600, maxWait = 10 * 60 * 1000}) {
+ this.getRequest = getRequest;
+ this.fetch = fetcher;
+ this.urls = urls;
+ this.sleep = sleep;
+ this.onChange = onChange;
+ this.download = download;
+ this.protocol = protocol;
+ this.pollInterval = pollInterval;
+ this.maxWait = maxWait;
+ this.token = null;
+ this.engines = {};
+ this.phase = 'idle';
+ this.message = '正在检测本机编译服务…';
+ this.log = '';
+ this.result = null;
+ this.stale = false;
+ this.generation = 0;
+ this.jobId = 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.previewGeneration = 0;
+ this.previewAbort = null;
+ this.previewTask = Promise.resolve(false);
+ }
+ get busy() { return this.phase === 'running'; }
+ emit() { if (!this.disposed) this.onChange(this); }
+ async responseJson(response) {
+ let body;
+ try { body = await response.json(); } catch (_) { throw new Error('编译服务返回了无法读取的响应,请检查服务终端。'); }
+ if (!response.ok) throw new Error(body.error || '编译服务请求失败(' + response.status + ')');
+ return body;
+ }
+ headers() { return {'X-VisualLaTeX-Token': this.token}; }
+ async connect() {
+ if (this.busy || this.disposed) return false;
+ if (this.protocol !== 'http:' && this.protocol !== 'https:') {
+ this.phase = 'unavailable';
+ this.message = '编译预览需要本机服务:在项目目录运行 node server.mjs,再打开终端显示的网址。';
+ this.emit(); return false;
+ }
+ try {
+ const body = await this.responseJson(await this.fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'}));
+ if (!body.token || !body.engines) throw new Error('当前页面没有连接到 VisualLaTeX 编译服务。');
+ this.token = body.token;
+ this.engines = body.engines;
+ this.phase = 'idle';
+ this.message = '选择主文件后编译;项目文件仅发送到本机服务。';
+ this.emit(); return true;
+ } catch (error) {
+ this.token = null;
+ this.phase = 'unavailable';
+ this.message = '无法连接编译服务。请运行 node server.mjs 并打开终端网址。' + error.message;
+ this.emit(); return false;
+ }
+ }
+ markChanged() {
+ if (this.result) {
+ try { this.stale = !this.fresh(this.getRequest()); }
+ catch (_) { this.stale = true; }
+ }
+ 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}); }
+ catch (_) { /* Server timeout/cleanup still bounds an unreachable job. */ }
+ }
+ async cancel() {
+ const id = this.jobId;
+ this.generation++;
+ this.abort?.abort();
+ this.abort = null;
+ this.jobId = null;
+ this.stopPreview();
+ if (this.busy) { this.phase = 'cancelled'; this.message = '已取消本次编译。'; }
+ this.emit();
+ await this.deleteJob(id);
+ }
+ clear() {
+ this.cancel();
+ if (this.result) this.urls.revokeObjectURL(this.result.url);
+ this.result = null;
+ this.releasePreview();
+ this.stale = false;
+ this.log = '';
+ this.emit();
+ }
+ stopPreview() {
+ this.previewGeneration++;
+ this.previewAbort?.abort();
+ this.previewAbort = null;
+ if (this.preview.state === 'loading') {
+ this.preview.state = this.preview.url ? 'ready' : 'empty';
+ this.preview.message = this.preview.url ? '' : '页面预览已暂停,可重新编译或导出 PDF。';
+ }
+ }
+ 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};
+ }
+ previewSession(result) {
+ this.stopPreview();
+ const generation = this.previewGeneration;
+ const abort = new AbortController();
+ this.previewAbort = abort;
+ return {abort, current: () => !this.disposed && this.result === result && generation === this.previewGeneration};
+ }
+ async previewResponse(url, signal) {
+ for (let attempt = 0; ; attempt++) {
+ const response = await this.fetch(url, {headers: this.headers(), credentials: 'same-origin', cache: 'no-store', signal});
+ if (response.ok) return response;
+ let body;
+ try { body = await response.json(); } catch (_) { body = {}; }
+ if (response.status === 409 && body.code === 'PREVIEW_BUSY' && attempt < 2) {
+ await this.sleep(400, signal); continue;
+ }
+ throw new Error(body.error || '页面预览暂时不可用;仍可导出 PDF。');
+ }
+ }
+ 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});
+ this.emit();
+ try {
+ const response = await this.previewResponse('/api/jobs/' + encodeURIComponent(result.jobId) + '/preview', session.abort.signal);
+ const metadata = await response.json();
+ if (!session.current()) return false;
+ if (!metadata.available) {
+ this.preview.state = 'unavailable';
+ this.preview.message = (metadata.reason || '未安装 Ghostscript,无法生成页面图片。') + ' 可使用浏览器原生预览或导出 PDF。';
+ this.emit(); return false;
+ }
+ if (!Number.isSafeInteger(metadata.pageCount) || metadata.pageCount < 1) throw new Error('无法读取 PDF 页数;仍可导出 PDF。');
+ this.preview.pageCount = metadata.pageCount;
+ this.preview.maxPages = Math.min(100, Number.isSafeInteger(metadata.maxPages) && metadata.maxPages > 0 ? metadata.maxPages : 100);
+ return await this.loadPage(1);
+ } catch (error) {
+ if (!session.current()) return false;
+ this.preview.state = 'failed';
+ this.preview.message = '页面预览失败:' + error.message + ' PDF 仍可导出。';
+ this.emit(); return false;
+ } finally { if (session.current()) this.previewAbort = null; }
+ }
+ async loadPage(page) {
+ const result = this.result;
+ if (!result || this.preview.targetKey !== result.key || !Number.isSafeInteger(page) || page < 1 ||
+ page > Math.min(this.preview.pageCount, this.preview.maxPages) || this.disposed) return false;
+ const session = this.previewSession(result);
+ this.preview.state = 'loading';
+ this.preview.message = '正在加载第 ' + page + ' 页…';
+ this.emit();
+ try {
+ const response = await this.previewResponse('/api/jobs/' + encodeURIComponent(result.jobId) + '/preview/' + page, session.abort.signal);
+ const blob = await response.blob();
+ if (!session.current()) return false;
+ if (!blob.size || (blob.type && !/^image\/png(?:;|$)/i.test(blob.type))) throw new Error('服务未返回有效页面图片。');
+ const url = this.urls.createObjectURL(new Blob([blob], {type: 'image/png'}));
+ if (this.preview.url) this.urls.revokeObjectURL(this.preview.url);
+ Object.assign(this.preview, {state: 'ready', message: '', url, key: result.key, page});
+ this.emit(); return true;
+ } catch (error) {
+ if (!session.current()) return false;
+ this.preview.state = 'failed';
+ this.preview.message = '页面加载失败:' + error.message + ' 可重试或导出 PDF。';
+ this.emit(); return false;
+ } finally { if (session.current()) this.previewAbort = null; }
+ }
+ async compile() {
+ if (this.busy || this.disposed) return false;
+ let request;
+ try {
+ request = this.getRequest();
+ if (!request.kind || !request.mainPath) throw new Error('请选择一个 LaTeX、PS 或 EPS 主文件。');
+ if (!this.token) throw new Error(this.message);
+ if (!this.engines[request.kind]?.available) throw new Error(request.kind === 'latex'
+ ? '未找到 LaTeX 引擎。安装 Tectonic 或 XeLaTeX 后重启本机服务。'
+ : '未找到 Ghostscript。安装后重启本机服务以渲染 PS/EPS。');
+ } catch (error) { this.phase = 'failed'; this.message = error.message; this.emit(); return false; }
+ const generation = ++this.generation;
+ this.stopPreview();
+ const snapshot = requestKey(request);
+ const abort = new AbortController();
+ this.abort = abort;
+ this.phase = 'running';
+ this.message = request.kind === 'postscript' ? '正在渲染 PostScript…' : '正在编译 LaTeX…';
+ this.log = '';
+ this.emit();
+ let job;
+ try {
+ job = await this.responseJson(await this.fetch('/api/compile', {
+ method: 'POST', headers: {...this.headers(), 'Content-Type': 'application/json'},
+ credentials: 'same-origin', body: JSON.stringify(request), signal: abort.signal
+ }));
+ 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; }
+ this.jobId = job.id;
+ const started = Date.now();
+ for (;;) {
+ const progress = await this.responseJson(await this.fetch('/api/jobs/' + encodeURIComponent(job.id), {
+ headers: this.headers(), credentials: 'same-origin', cache: 'no-store', signal: abort.signal
+ }));
+ if (generation !== this.generation) return false;
+ this.log = typeof progress.log === 'string' ? progress.log : '';
+ this.emit();
+ if (progress.state === 'failed') throw new Error(progress.error || '编译失败,请查看日志。');
+ if (progress.state === 'cancelled') throw new Error('本次编译已取消。');
+ if (progress.state === 'succeeded') {
+ const pdfUrl = '/api/jobs/' + encodeURIComponent(job.id) + '/pdf';
+ if (progress.pdfUrl && progress.pdfUrl !== pdfUrl) throw new Error('编译服务返回的 PDF 路径无效。');
+ const response = await this.fetch(pdfUrl, {headers: this.headers(), credentials: 'same-origin', signal: abort.signal});
+ if (!response.ok) { await this.responseJson(response); throw new Error('无法下载编译结果。'); }
+ const blob = await response.blob();
+ if (generation !== this.generation) return false;
+ // A late result must not replace the preview of a different
+ // project or source revision, even if its fetch ignored abort.
+ if (snapshot !== requestKey(this.getRequest())) {
+ this.phase = 'stale';
+ this.message = '源码或编译目标已改变,本次结果未替换预览。请重新编译。';
+ this.stale = Boolean(this.result);
+ this.emit(); return false;
+ }
+ if (blob.size === 0) throw new Error('编译器没有生成 PDF 内容。');
+ const url = this.urls.createObjectURL(new Blob([blob], {type: 'application/pdf'}));
+ if (this.result) this.urls.revokeObjectURL(this.result.url);
+ this.result = {url, key: snapshot, jobId: job.id, mainPath: request.mainPath, engine: progress.engine || '', size: blob.size};
+ this.stale = false;
+ this.phase = 'succeeded';
+ this.message = request.mainPath + ' · PDF 已更新';
+ this.emit();
+ this.previewTask = this.loadPreview(this.result);
+ return true;
+ }
+ if (progress.state !== 'running') throw new Error('无法识别编译任务状态。');
+ if (Date.now() - started > this.maxWait) throw new Error('编译等待超时,请检查日志或重试。');
+ await this.sleep(this.pollInterval, abort.signal);
+ }
+ } catch (error) {
+ if (generation !== this.generation) return false;
+ this.phase = 'failed';
+ this.message = error.message || '编译失败,请查看日志。';
+ this.stale = Boolean(this.result);
+ this.emit();
+ // 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);
+ return false;
+ } finally {
+ if (generation === this.generation) { this.abort = null; this.jobId = null; }
+ }
+ }
+ async exportPdf() {
+ if (this.busy) return false;
+ try {
+ const request = this.getRequest();
+ if (!this.fresh(request) && !await this.compile()) return false;
+ if (!this.fresh(this.getRequest())) return false;
+ this.stale = false;
+ this.download(this.result.url, this.result.mainPath.split('/').pop().replace(/\.[^.]+$/, '') + '.pdf');
+ this.emit(); return true;
+ } catch (error) { this.message = error.message; this.emit(); return false; }
+ }
+ destroy() {
+ this.disposed = true;
+ this.cancel();
+ if (this.result) this.urls.revokeObjectURL(this.result.url);
+ this.result = null;
+ this.releasePreview();
+ }
+ }
+
+ function initCompilation(app) {
+ const $ = id => document.getElementById(id);
+ let lastModel = app.model;
+ let lastMainPath = app.model.mainPath;
+ let targetList = '';
+ let shownUrl = null;
+ let shownPageUrl = null;
+ const preview = $('pdfPreviewPane');
+ function showPreview(show = true) {
+ preview.hidden = !show;
+ document.body.classList.toggle('compilation-visible', show);
+ $('btnTogglePreview').setAttribute('aria-expanded', String(show));
+ }
+ app.revealEditor = () => {
+ if (window.matchMedia?.('(max-width: 700px)').matches) showPreview(false);
+ };
+ function targets() {
+ const files = app.model.list().filter(kindForFile);
+ const signature = JSON.stringify(files.map(file => file.path));
+ const mainChanged = app.model.mainPath !== lastMainPath;
+ if (signature !== targetList || app.model !== lastModel || mainChanged) {
+ const old = app.model === lastModel ? $('compileTarget').value : null;
+ $('compileTarget').replaceChildren();
+ for (const file of files) {
+ const option = document.createElement('option');
+ option.value = file.path; option.textContent = file.path;
+ $('compileTarget').appendChild(option);
+ }
+ $('compileTarget').value = mainChanged && files.some(file => file.path === app.model.mainPath) ? app.model.mainPath
+ : files.some(file => file.path === old) ? old
+ : files.some(file => file.path === app.model.mainPath) ? app.model.mainPath : files[0]?.path || '';
+ targetList = signature;
+ lastMainPath = app.model.mainPath;
+ }
+ }
+ const controller = new CompilationController({
+ getRequest() {
+ app.sync();
+ targets();
+ const mainPath = $('compileTarget').value;
+ const file = app.model.get(mainPath);
+ return {project: JSON.parse(app.model.serialize()), kind: kindForFile(file), mainPath};
+ },
+ onChange: render,
+ download(url, name) {
+ const link = document.createElement('a'); link.href = url; link.download = name;
+ document.body.appendChild(link); link.click(); link.remove();
+ }
+ });
+ function render() {
+ const kind = kindForFile(app.model.get($('compileTarget').value));
+ const engine = controller.engines[kind];
+ $('btnCompile').disabled = controller.busy || !controller.token || !kind;
+ $('btnCompile').textContent = kind === 'postscript' ? '渲染预览' : '编译预览';
+ $('btnCancelCompile').hidden = !controller.busy;
+ $('btnExportPdf').disabled = controller.busy || !controller.token || !kind;
+ $('compileTarget').disabled = controller.busy;
+ $('btnCompilerRetry').disabled = controller.busy;
+ $('btnClosePreview').textContent = app.mode === 'visual' ? '返回可视化编辑' : '返回源码编辑';
+ $('compilerStatus').textContent = engine?.available ? engine.label : controller.token ? (kind === 'postscript' ? '需要 Ghostscript' : '需要 LaTeX 引擎') : '本机编译';
+ $('compileStatus').textContent = controller.message;
+ $('compileStatus').dataset.state = controller.phase;
+ $('compileLog').textContent = controller.log || '编译后将在这里显示引擎日志。';
+ $('previewStale').hidden = !controller.stale;
+ $('previewStale').textContent = '当前显示上一次结果;请重新编译以反映最新源码。';
+ $('pdfEmpty').hidden = Boolean(controller.result);
+ const page = controller.preview;
+ const showingImage = Boolean(page.url && controller.result && (page.key === controller.result.key || page.state === 'loading'));
+ $('pdfPageView').hidden = !showingImage;
+ $('pdfFrame').hidden = !controller.result || showingImage || page.state === 'loading';
+ $('pagePreviewStatus').hidden = !controller.result || !page.message;
+ $('pagePreviewStatus').textContent = page.message;
+ const knownPages = Boolean(controller.result && page.targetKey === controller.result.key && page.pageCount);
+ $('previewPageControls').hidden = !knownPages;
+ const currentPage = page.key === controller.result?.key ? page.page : 0;
+ const limit = Math.min(page.pageCount, page.maxPages);
+ $('previewPageNumber').textContent = (currentPage || '—') + ' / ' + page.pageCount + (page.pageCount > page.maxPages ? '(预览前 ' + page.maxPages + ' 页)' : '');
+ $('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);
+ $('previewStale').hidden = !controller.stale && !(showingImage && page.key !== controller.result.key);
+ $('previewStale').textContent = showingImage && page.key !== controller.result.key
+ ? '当前仍显示上一次编译的页面,正在加载新结果。' : '当前显示上一次结果;请重新编译以反映最新源码。';
+ $('pdfOpen').hidden = !controller.result;
+ if (page.url !== shownPageUrl) {
+ shownPageUrl = page.url;
+ if (shownPageUrl) $('pdfPageImage').src = shownPageUrl;
+ else $('pdfPageImage').removeAttribute('src');
+ }
+ $('pdfPageImage').alt = 'PDF 第 ' + (page.page || 1) + ' 页';
+ if (controller.result?.url !== shownUrl) {
+ shownUrl = controller.result?.url || null;
+ if (shownUrl) { $('pdfFrame').src = shownUrl; $('pdfOpen').href = shownUrl; }
+ else { $('pdfFrame').removeAttribute('src'); $('pdfOpen').removeAttribute('href'); }
+ }
+ $('previewDocument').textContent = controller.result ? controller.result.mainPath : '尚未生成 PDF';
+ if (controller.phase === 'failed') $('compileLogs').open = true;
+ }
+ targets();
+ const previousChange = app.onChange;
+ app.onChange = () => {
+ previousChange();
+ targets();
+ if (app.model !== lastModel) { lastModel = app.model; controller.clear(); }
+ else controller.markChanged();
+ render();
+ };
+ $('compileTarget').addEventListener('change', () => { controller.markChanged(); render(); });
+ $('setMainFile').addEventListener('click', () => {
+ targets();
+ // An explicit "set main file" action also wins when this file was
+ // already main but the preview dropdown was manually set elsewhere.
+ if (kindForFile(app.model.get(app.model.mainPath))) $('compileTarget').value = app.model.mainPath;
+ controller.markChanged(); render();
+ });
+ $('btnTogglePreview').addEventListener('click', () => showPreview(preview.hidden));
+ $('btnClosePreview').addEventListener('click', () => {
+ showPreview(false);
+ $(app.mode === 'visual' ? 'editorArea' : 'sourceArea').focus();
+ });
+ $('btnCompile').addEventListener('click', () => { showPreview(); controller.compile(); });
+ $('btnCancelCompile').addEventListener('click', () => controller.cancel());
+ $('btnExportPdf').addEventListener('click', () => { showPreview(); controller.exportPdf(); });
+ $('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(); });
+ document.addEventListener('keydown', event => {
+ if (event.defaultPrevented || event.target?.closest?.('[role="dialog"]')) return;
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
+ event.preventDefault(); showPreview(); controller.compile();
+ }
+ });
+ window.addEventListener('pagehide', event => {
+ if (event.persisted) controller.cancel();
+ else controller.destroy();
+ });
+ window.addEventListener('pageshow', event => {
+ if (!event.persisted) return;
+ targets(); controller.markChanged();
+ controller.connect().then(connected => {
+ if (connected && controller.result && (!controller.preview.url || controller.preview.key !== controller.result.key)) {
+ controller.previewTask = controller.loadPreview();
+ }
+ });
+ });
+ app.compilation = controller;
+ render();
+ controller.connect();
+ return controller;
+ }
+ root.CompilationController = CompilationController;
+ root.compilationKindForFile = kindForFile;
+ root.initCompilation = initCompilation;
+})(globalThis);
diff --git a/project-archive.js b/project-archive.js
new file mode 100644
index 0000000..5b2adab
--- /dev/null
+++ b/project-archive.js
@@ -0,0 +1,238 @@
+/* ZIP project import/export. Classic script; no network or runtime dependencies. */
+(function(root) {
+ 'use strict';
+ const MAX_FILES = 500;
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
+ const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
+ const encoder = new TextEncoder();
+ const decoder = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true});
+ const crcTable = new Uint32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let value = n;
+ for (let bit = 0; bit < 8; bit++) value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
+ crcTable[n] = value >>> 0;
+ }
+ function fail(message) { throw new Error('ZIP:' + message); }
+ function validatePath(path) {
+ if (typeof path !== 'string' || !path || path.length > 1024) fail('文件路径为空或过长');
+ const normalized = path.replace(/\\/g, '/');
+ if (normalized.startsWith('/') || /[:\u0000-\u001f\u007f]/.test(normalized)) fail('不安全的文件路径:' + path);
+ if (normalized.split('/').some(part => part === '' || part === '.' || part === '..')) fail('不安全的文件路径:' + path);
+ return normalized;
+ }
+ const pathKey = path => path.normalize('NFC').toLowerCase();
+ function crc32(bytes) {
+ let crc = 0xffffffff;
+ for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
+ return (crc ^ 0xffffffff) >>> 0;
+ }
+ function checkBytes(bytes) {
+ if (!(bytes instanceof Uint8Array)) fail('文件内容必须是 Uint8Array');
+ return bytes;
+ }
+ function writeZip(files, {comment = ''} = {}) {
+ if (!Array.isArray(files) || files.length > MAX_FILES) fail('项目最多包含 ' + MAX_FILES + ' 个文件');
+ if (typeof comment !== 'string') fail('压缩包备注必须是字符串');
+ const commentBytes = encoder.encode(comment);
+ if (commentBytes.length > 0xffff) fail('压缩包备注不能超过 65535 字节');
+ const names = new Set();
+ let total = 0;
+ let length = 22 + commentBytes.length;
+ const records = files.map(file => {
+ const path = validatePath(file.path);
+ const key = pathKey(path);
+ if (names.has(key)) fail('重复文件路径:' + path);
+ names.add(key);
+ const bytes = checkBytes(file.bytes);
+ total += bytes.length;
+ if (total > MAX_TOTAL_BYTES) fail('项目解压后总大小不能超过 50 MB');
+ const name = encoder.encode(path);
+ length += 30 + name.length + bytes.length + 46 + name.length;
+ return {path, name, bytes, crc: crc32(bytes)};
+ });
+ const out = new Uint8Array(length);
+ const view = new DataView(out.buffer);
+ let offset = 0;
+ for (const record of records) {
+ record.offset = offset;
+ view.setUint32(offset, 0x04034b50, true);
+ view.setUint16(offset + 4, 20, true);
+ view.setUint16(offset + 6, 0x0800, true);
+ view.setUint16(offset + 12, 0x0021, true); // 1980-01-01; deterministic archive.
+ view.setUint32(offset + 14, record.crc, true);
+ view.setUint32(offset + 18, record.bytes.length, true);
+ view.setUint32(offset + 22, record.bytes.length, true);
+ view.setUint16(offset + 26, record.name.length, true);
+ out.set(record.name, offset + 30);
+ out.set(record.bytes, offset + 30 + record.name.length);
+ offset += 30 + record.name.length + record.bytes.length;
+ }
+ const centralOffset = offset;
+ for (const record of records) {
+ view.setUint32(offset, 0x02014b50, true);
+ view.setUint16(offset + 4, 20, true);
+ view.setUint16(offset + 6, 20, true);
+ view.setUint16(offset + 8, 0x0800, true);
+ view.setUint16(offset + 14, 0x0021, true);
+ view.setUint32(offset + 16, record.crc, true);
+ view.setUint32(offset + 20, record.bytes.length, true);
+ view.setUint32(offset + 24, record.bytes.length, true);
+ view.setUint16(offset + 28, record.name.length, true);
+ view.setUint32(offset + 42, record.offset, true);
+ out.set(record.name, offset + 46);
+ offset += 46 + record.name.length;
+ }
+ view.setUint32(offset, 0x06054b50, true);
+ view.setUint16(offset + 8, records.length, true);
+ view.setUint16(offset + 10, records.length, true);
+ view.setUint32(offset + 12, offset - centralOffset, true);
+ view.setUint32(offset + 16, centralOffset, true);
+ view.setUint16(offset + 20, commentBytes.length, true);
+ out.set(commentBytes, offset + 22);
+ return out;
+ }
+ async function inflate(bytes, expected) {
+ if (typeof DecompressionStream !== 'function') fail('当前浏览器不支持解压 deflate;请解压 ZIP 后导入文件夹');
+ let stream;
+ try { stream = new DecompressionStream('deflate-raw'); }
+ catch (_) { fail('当前浏览器不支持解压 deflate;请解压 ZIP 后导入文件夹'); }
+ const reader = new Blob([bytes]).stream().pipeThrough(stream).getReader();
+ const chunks = [];
+ let length = 0;
+ try {
+ for (;;) {
+ const {value, done} = await reader.read();
+ if (done) break;
+ length += value.length;
+ if (length > expected || length > MAX_TOTAL_BYTES) {
+ await reader.cancel();
+ fail('解压内容超过声明大小或 50 MB 限制');
+ }
+ chunks.push(value);
+ }
+ } catch (error) {
+ if (error.message.startsWith('ZIP:')) throw error;
+ fail('压缩数据损坏,无法解压');
+ } finally {
+ reader.releaseLock();
+ }
+ if (length !== expected) fail('解压后的文件大小不匹配');
+ const result = new Uint8Array(length);
+ let offset = 0;
+ for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; }
+ return result;
+ }
+ async function readZip(input, {includeComment = false} = {}) {
+ const bytes = checkBytes(input);
+ if (bytes.length > MAX_ARCHIVE_BYTES) fail('压缩包不能超过 100 MB');
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ function requireBytes(offset, size, limit = bytes.length) {
+ if (!Number.isSafeInteger(offset) || offset < 0 || size < 0 || offset + size > limit) fail('压缩包结构不完整');
+ }
+ function extras(offset, length) {
+ const end = offset + length;
+ while (offset < end) {
+ requireBytes(offset, 4, end);
+ const id = view.getUint16(offset, true);
+ const size = view.getUint16(offset + 2, true);
+ requireBytes(offset + 4, size, end);
+ if (id === 1) fail('暂不支持 ZIP64');
+ offset += 4 + size;
+ }
+ }
+ function filename(offset, length) {
+ try { return decoder.decode(bytes.subarray(offset, offset + length)); }
+ catch (_) { fail('文件名必须使用 UTF-8 编码,请解压后导入文件夹'); }
+ }
+ let eocd = -1;
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) {
+ if (view.getUint32(i, true) === 0x06054b50 && i + 22 + view.getUint16(i + 20, true) === bytes.length) { eocd = i; break; }
+ }
+ if (eocd < 0) fail('未找到有效的 ZIP 目录');
+ const count = view.getUint16(eocd + 10, true);
+ const centralSize = view.getUint32(eocd + 12, true);
+ const centralOffset = view.getUint32(eocd + 16, true);
+ if (count === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff) fail('暂不支持 ZIP64');
+ if (view.getUint16(eocd + 4, true) || view.getUint16(eocd + 6, true) || view.getUint16(eocd + 8, true) !== count) fail('不支持分卷压缩包');
+ if (count > MAX_FILES * 2 + 1) fail('压缩包目录项目过多');
+ requireBytes(centralOffset, centralSize, eocd);
+ if (centralOffset + centralSize !== eocd) fail('ZIP 目录边界不匹配');
+ const records = [];
+ const names = new Set();
+ let total = 0;
+ let fileCount = 0;
+ let cursor = centralOffset;
+ for (let n = 0; n < count; n++) {
+ requireBytes(cursor, 46, eocd);
+ if (view.getUint32(cursor, true) !== 0x02014b50) fail('ZIP 目录条目损坏');
+ const flags = view.getUint16(cursor + 8, true);
+ const method = view.getUint16(cursor + 10, true);
+ const crc = view.getUint32(cursor + 16, true);
+ const compressedSize = view.getUint32(cursor + 20, true);
+ const size = view.getUint32(cursor + 24, true);
+ const nameLength = view.getUint16(cursor + 28, true);
+ const extraLength = view.getUint16(cursor + 30, true);
+ const commentLength = view.getUint16(cursor + 32, true);
+ const localOffset = view.getUint32(cursor + 42, true);
+ if (flags & 0x0041) fail('不支持加密 ZIP');
+ if (flags & ~0x080e) fail('ZIP 使用了不支持的标志');
+ if (method !== 0 && method !== 8) fail('仅支持 stored 或 deflate 压缩方式');
+ if (size === 0xffffffff || compressedSize === 0xffffffff || localOffset === 0xffffffff) fail('暂不支持 ZIP64');
+ if (view.getUint16(cursor + 34, true)) fail('不支持分卷压缩包');
+ if ((view.getUint32(cursor + 38, true) >>> 16 & 0xf000) === 0xa000) fail('不支持符号链接文件');
+ requireBytes(cursor + 46, nameLength + extraLength + commentLength, eocd);
+ extras(cursor + 46 + nameLength, extraLength);
+ const rawName = filename(cursor + 46, nameLength);
+ const directory = /[\\/]$/.test(rawName);
+ const path = validatePath(directory ? rawName.slice(0, -1) : rawName);
+ const key = pathKey(path);
+ if (names.has(key)) fail('重复文件路径:' + path);
+ names.add(key);
+ if (directory && size !== 0) fail('目录条目包含文件数据');
+ if (!directory && ++fileCount > MAX_FILES) fail('项目最多包含 ' + MAX_FILES + ' 个文件');
+ total += size;
+ if (total > MAX_TOTAL_BYTES) fail('项目解压后总大小不能超过 50 MB');
+ requireBytes(localOffset, 30, centralOffset);
+ if (view.getUint32(localOffset, true) !== 0x04034b50) fail('文件本地头损坏');
+ if (view.getUint16(localOffset + 6, true) !== flags || view.getUint16(localOffset + 8, true) !== method) fail('文件头与目录不一致');
+ const localNameLength = view.getUint16(localOffset + 26, true);
+ const localExtraLength = view.getUint16(localOffset + 28, true);
+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
+ requireBytes(localOffset + 30, localNameLength + localExtraLength, centralOffset);
+ if (filename(localOffset + 30, localNameLength) !== rawName) fail('文件头名称与目录不一致');
+ extras(localOffset + 30 + localNameLength, localExtraLength);
+ requireBytes(dataOffset, compressedSize, centralOffset);
+ const localCrc = view.getUint32(localOffset + 14, true);
+ const localCompressed = view.getUint32(localOffset + 18, true);
+ const localSize = view.getUint32(localOffset + 22, true);
+ let recordEnd = dataOffset + compressedSize;
+ if (flags & 8) {
+ if ((localCrc !== 0 && localCrc !== crc) || (localCompressed !== 0 && localCompressed !== compressedSize) || (localSize !== 0 && localSize !== size)) fail('文件大小声明不一致');
+ requireBytes(recordEnd, 12, centralOffset);
+ const descriptor = view.getUint32(recordEnd, true) === 0x08074b50 ? recordEnd + 4 : recordEnd;
+ requireBytes(descriptor, 12, centralOffset);
+ if (view.getUint32(descriptor, true) !== crc || view.getUint32(descriptor + 4, true) !== compressedSize || view.getUint32(descriptor + 8, true) !== size) fail('文件数据描述符损坏');
+ recordEnd = descriptor + 12;
+ } else if (localCrc !== crc || localCompressed !== compressedSize || localSize !== size) fail('文件头大小或校验值与目录不一致');
+ if (method === 0 && compressedSize !== size) fail('未压缩文件大小不匹配');
+ records.push({path, directory, method, crc, size, localOffset, recordEnd, dataOffset, compressedSize});
+ cursor += 46 + nameLength + extraLength + commentLength;
+ }
+ if (cursor !== eocd) fail('目录条目数与目录大小不匹配');
+ const ranges = [...records].sort((a, b) => a.localOffset - b.localOffset);
+ for (let i = 1; i < ranges.length; i++) if (ranges[i].localOffset < ranges[i - 1].recordEnd) fail('文件数据区域重叠');
+ const result = [];
+ for (const record of records) {
+ const compressed = bytes.subarray(record.dataOffset, record.dataOffset + record.compressedSize);
+ const content = record.method === 0 ? compressed.slice() : await inflate(compressed, record.size);
+ if (crc32(content) !== record.crc) fail('文件校验失败:' + record.path);
+ if (!record.directory) result.push({path: record.path, bytes: content});
+ }
+ // ZIP comments have no reliable charset flag. Decode external comments
+ // permissively so metadata cannot prevent importing otherwise valid files.
+ return includeComment
+ ? {files: result, comment: new TextDecoder('utf-8', {ignoreBOM: true}).decode(bytes.subarray(eocd + 22))}
+ : result;
+ }
+ root.ProjectArchive = Object.freeze({readZip, writeZip, crc32, validatePath, MAX_FILES, MAX_TOTAL_BYTES, MAX_ARCHIVE_BYTES});
+})(globalThis);
diff --git a/project-model.js b/project-model.js
new file mode 100644
index 0000000..2053835
--- /dev/null
+++ b/project-model.js
@@ -0,0 +1,340 @@
+/* Canonical project files and per-file editing history. No DOM or network access. */
+(function (global) {
+ 'use strict';
+
+ const MAX_FILES = 500;
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
+ const HISTORY_LIMIT = 100;
+ const HISTORY_BYTES = 8 * 1024 * 1024;
+ const encoder = new TextEncoder();
+
+ function validatePath(value) {
+ if (typeof value !== 'string' || !value || value.length > 1024) throw new Error('文件路径不能为空且不能超过 1024 字符');
+ const path = value.replace(/\\/g, '/');
+ if (path.startsWith('/') || /[:\u0000-\u001f\u007f]/.test(path)) throw new Error('文件路径必须是项目内的相对路径');
+ if (path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('文件路径不能包含空目录、. 或 ..');
+ return path;
+ }
+
+ function language(path) {
+ const extension = String(path).split('.').pop().toLowerCase();
+ if (['tex', 'latex', 'ltx', 'sty', 'cls'].includes(extension)) return 'latex';
+ if (extension === 'bib') return 'bibtex';
+ if (extension === 'ps' || extension === 'eps') return 'postscript';
+ return 'text';
+ }
+
+ const pathKey = path => path.normalize('NFC').toLowerCase();
+
+ function checkText(text, encoding) {
+ if (typeof text !== 'string') throw new Error('文本文件必须提供字符串内容');
+ if (encoding === 'latin1') {
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) > 255) throw new Error('Latin-1 文件不能保存此字符;请先转换为 UTF-8 编码');
+ }
+ return text.length;
+ }
+ for (let i = 0; i < text.length; i++) {
+ const code = text.charCodeAt(i);
+ if (code >= 0xd800 && code <= 0xdbff) {
+ const next = text.charCodeAt(++i);
+ if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('文本包含不完整的 Unicode 字符');
+ } else if (code >= 0xdc00 && code <= 0xdfff) throw new Error('文本包含不完整的 Unicode 字符');
+ }
+ return encoder.encode(text).length;
+ }
+
+ function normalizeFile(input) {
+ if (!input || typeof input !== 'object') throw new Error('文件记录无效');
+ const path = validatePath(input.path);
+ if (input.kind === 'binary') {
+ const base64 = input.base64;
+ if (typeof base64 !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)) {
+ throw new Error('二进制文件的 Base64 内容无效:' + path);
+ }
+ const size = base64.length / 4 * 3 - (base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0);
+ return {file: {path, kind: 'binary', base64}, size};
+ }
+ if (input.kind !== undefined && input.kind !== 'text') throw new Error('未知文件类型:' + path);
+ const encoding = input.encoding || 'utf-8';
+ if (encoding !== 'utf-8' && encoding !== 'latin1') throw new Error('不支持的文本编码:' + encoding);
+ const size = checkText(input.text, encoding);
+ return {file: {path, kind: 'text', text: input.text, encoding}, size};
+ }
+
+ function readGroup(text, start, open = '{', close = '}') {
+ if (text[start] !== open) return null;
+ let depth = 1;
+ let braces = 0;
+ for (let i = start + 1; i < text.length; i++) {
+ if (text[i] === '\\') { i++; continue; }
+ if (text[i] === '%') {
+ const end = text.indexOf('\n', i);
+ if (end < 0) return null;
+ i = end;
+ continue;
+ }
+ if (open !== '{') {
+ if (text[i] === '{') braces++;
+ else if (text[i] === '}') braces--;
+ if (braces > 0) continue;
+ }
+ if (text[i] === open) depth++;
+ else if (text[i] === close && --depth === 0) return {start, end: i + 1, content: text.slice(start + 1, i)};
+ }
+ return null;
+ }
+
+ function scanLatex(text, path, result) {
+ const citations = new Set(['cite', 'citep', 'citet', 'citealp', 'citealt', 'citeauthor', 'citeyear', 'citeyearpar', 'nocite',
+ 'autocite', 'parencite', 'textcite', 'footcite', 'footcitetext', 'smartcite', 'supercite', 'fullcite']);
+ const references = new Set(['ref', 'eqref', 'pageref', 'autoref', 'nameref', 'cref', 'vref', 'vpageref']);
+ for (let i = 0; i < text.length;) {
+ if (text[i] === '%') { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end + 1; continue; }
+ if (text[i] !== '\\') { i++; continue; }
+ const match = /^\\([A-Za-z@]+\*?|[^\r\n])/.exec(text.slice(i));
+ if (!match) { i++; continue; }
+ const start = i;
+ const name = match[1].replace(/\*$/, '').toLowerCase();
+ i += match[0].length;
+ if (name === 'verb' || name === 'lstinline') {
+ if (name === 'lstinline' && text[i] === '[') { const opt = readGroup(text, i, '[', ']'); if (opt) i = opt.end; }
+ const delimiter = text[i];
+ if (delimiter && !/\s/.test(delimiter)) { const end = text.indexOf(delimiter, i + 1); i = end < 0 ? text.length : end + 1; }
+ continue;
+ }
+ while (/\s/.test(text[i] || '') && i < text.length) i++;
+ if (name === 'begin') {
+ const env = readGroup(text, i);
+ if (env && /^(?:verbatim\*?|Verbatim|lstlisting|minted|comment)$/.test(env.content)) {
+ const closing = '\\end{' + env.content + '}';
+ const end = text.indexOf(closing, env.end);
+ i = end < 0 ? text.length : end + closing.length;
+ }
+ continue;
+ }
+ if (name !== 'label' && !citations.has(name) && !references.has(name)) continue;
+ while (text[i] === '[') {
+ const optional = readGroup(text, i, '[', ']');
+ if (!optional) break;
+ i = optional.end;
+ while (/\s/.test(text[i] || '') && i < text.length) i++;
+ }
+ const group = readGroup(text, i);
+ if (!group) continue; // Incomplete source remains editable; it simply has no index entry yet.
+ const target = name === 'label' ? result.labels : citations.has(name) ? result.citations : result.references;
+ for (const key of group.content.split(',').map(key => key.trim()).filter(Boolean)) {
+ target.push({key, path, offset: start});
+ }
+ i = group.end;
+ }
+ }
+
+ function scanBibtex(text, path, result) {
+ for (let i = 0; i < text.length;) {
+ if (text[i] === '%') { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end + 1; continue; }
+ if (text[i] !== '@') { i++; continue; }
+ const match = /^@([A-Za-z]+)\s*([{(])/.exec(text.slice(i));
+ if (!match) { i++; continue; }
+ const type = match[1].toLowerCase();
+ const start = i;
+ const groupStart = i + match[0].length - 1;
+ const group = readGroup(text, groupStart, match[2], match[2] === '{' ? '}' : ')');
+ if (!group) { i += match[0].length; continue; }
+ if (!['comment', 'preamble', 'string'].includes(type)) {
+ const key = /^\s*([^,\s{}()]+)\s*,/.exec(group.content);
+ if (key) result.bibliography.push({key: key[1], path, offset: start, type});
+ }
+ i = group.end;
+ }
+ }
+
+ class ProjectModel {
+ constructor(options = {}) {
+ if (!options || typeof options !== 'object') throw new Error('项目数据无效');
+ this.name = options.name === undefined ? '未命名项目' : options.name;
+ this._files = new Map();
+ this._sizes = new Map();
+ this._histories = new Map();
+ this._activePath = null;
+ this._mainPath = null;
+ this.totalBytes = 0;
+ if (options.files !== undefined && !Array.isArray(options.files)) throw new Error('项目文件列表无效');
+ this.addMany(options.files || []);
+ this.activePath = options.activePath === undefined ? this._activePath : options.activePath;
+ this.mainPath = options.mainPath === undefined ? this._mainPath : options.mainPath;
+ }
+
+ static validatePath(path) { return validatePath(path); }
+ static language(path) { return language(path); }
+ language(path = this.activePath) { return language(path || ''); }
+ get name() { return this._name; }
+ set name(value) {
+ if (typeof value !== 'string' || !value.trim() || value.length > 200) throw new Error('项目名称无效');
+ this._name = value;
+ }
+ get activePath() { return this._activePath; }
+ set activePath(path) {
+ if (path === null && this._files.size === 0) { this._activePath = null; return; }
+ const normalized = validatePath(path);
+ if (!this._files.has(normalized)) throw new Error('文件不存在:' + normalized);
+ if (this._activePath !== normalized) this._closeGroup(this._activePath);
+ this._activePath = normalized;
+ }
+ get mainPath() { return this._mainPath; }
+ set mainPath(path) {
+ if (path === null) { this._mainPath = null; return; }
+ const normalized = validatePath(path);
+ if (this._files.get(normalized)?.kind !== 'text') throw new Error('主文件必须是项目中的文本文件');
+ this._mainPath = normalized;
+ }
+ get(path = this.activePath) {
+ const file = this._files.get(path);
+ return file ? {...file, language: language(file.path)} : null;
+ }
+ list() { return Array.from(this._files.values(), file => ({...file, language: language(file.path)})); }
+ select(path) { this.activePath = path; return this.get(); }
+
+ add(file) { return this.addMany([file])[0]; }
+ addMany(files) {
+ if (!Array.isArray(files)) throw new Error('导入文件列表无效');
+ if (this._files.size + files.length > MAX_FILES) throw new Error('项目最多包含 500 个文件');
+ const names = new Set(Array.from(this._files.keys(), pathKey));
+ 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));
+ size += record.size;
+ if (size > MAX_TOTAL_BYTES) throw new Error('项目大小不能超过 50 MB');
+ return record;
+ });
+ // All validation happens before any file is added.
+ for (const {file, size: fileSize} of records) {
+ this._files.set(file.path, file);
+ this._sizes.set(file.path, fileSize);
+ if (!this._activePath) this._activePath = file.path;
+ if (!this._mainPath && file.kind === 'text') this._mainPath = file.path;
+ }
+ this.totalBytes = size;
+ return records.map(({file}) => this.get(file.path));
+ }
+
+ rename(oldPath, nextPath) {
+ 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);
+ 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);
+ if (this._histories.has(oldPath)) { this._histories.set(path, this._histories.get(oldPath)); this._histories.delete(oldPath); }
+ if (this._activePath === oldPath) this._activePath = path;
+ if (this._mainPath === oldPath) this._mainPath = path;
+ this._closeGroup(path);
+ return this.get(path);
+ }
+
+ remove(path) {
+ const removed = this.get(path);
+ if (!removed) throw new Error('文件不存在:' + path);
+ this.totalBytes -= this._sizes.get(path);
+ this._files.delete(path);
+ this._sizes.delete(path);
+ this._histories.delete(path);
+ if (this._activePath === path) this._activePath = this._files.keys().next().value || null;
+ if (this._mainPath === path) this._mainPath = Array.from(this._files.values()).find(file => file.kind === 'text')?.path || null;
+ return removed;
+ }
+
+ _history(path) {
+ if (!this._histories.has(path)) this._histories.set(path, {undo: [], redo: [], groupedAt: 0});
+ return this._histories.get(path);
+ }
+ _closeGroup(path) { if (this._histories.has(path)) this._histories.get(path).groupedAt = 0; }
+ _prune(entries, encoding) {
+ let size = entries.reduce((sum, text) => sum + (encoding === 'latin1' ? text.length : encoder.encode(text).length), 0);
+ while (entries.length > HISTORY_LIMIT || (entries.length > 1 && size > HISTORY_BYTES)) {
+ const text = entries.shift();
+ size -= encoding === 'latin1' ? text.length : encoder.encode(text).length;
+ }
+ }
+ _replacementSize(file, text) {
+ const size = checkText(text, file.encoding);
+ if (this.totalBytes - this._sizes.get(file.path) + size > MAX_TOTAL_BYTES) throw new Error('项目大小不能超过 50 MB');
+ return size;
+ }
+ _replace(file, text, size) {
+ this.totalBytes += size - this._sizes.get(file.path);
+ this._sizes.set(file.path, size);
+ this._files.set(file.path, {...file, text});
+ return this.get(file.path);
+ }
+ update(text, {group = false} = {}) {
+ const file = this._files.get(this.activePath);
+ if (!file || file.kind !== 'text') throw new Error('请选择可编辑的文本文件');
+ if (text === file.text) return this.get();
+ const size = this._replacementSize(file, text);
+ const history = this._history(file.path);
+ const now = Date.now();
+ if (!group || !history.groupedAt || now - history.groupedAt > 700 || history.redo.length) history.undo.push(file.text);
+ this._prune(history.undo, file.encoding);
+ history.redo = [];
+ history.groupedAt = group ? now : 0;
+ return this._replace(file, text, size);
+ }
+ setEncoding(path, encoding) {
+ const file = this._files.get(path);
+ if (!file || file.kind !== 'text') throw new Error('请选择可编辑的文本文件');
+ if (encoding !== 'utf-8' && encoding !== 'latin1') throw new Error('不支持的文本编码:' + encoding);
+ if (encoding === file.encoding) return this.get(path);
+ const converted = {...file, encoding};
+ const size = this._replacementSize(converted, file.text);
+ // An encoding change starts a new edit history so an old state cannot
+ // silently restore characters that the chosen encoding cannot represent.
+ this._histories.delete(path);
+ return this._replace(converted, file.text, size);
+ }
+ canUndo() { return Boolean(this._histories.get(this.activePath)?.undo.length); }
+ canRedo() { return Boolean(this._histories.get(this.activePath)?.redo.length); }
+ undo() { return this._restore('undo', 'redo'); }
+ redo() { return this._restore('redo', 'undo'); }
+ _restore(from, to) {
+ const file = this._files.get(this.activePath);
+ const history = this._histories.get(this.activePath);
+ if (!file || file.kind !== 'text' || !history?.[from].length) return null;
+ const text = history[from][history[from].length - 1];
+ const size = this._replacementSize(file, text);
+ history[from].pop();
+ history[to].push(file.text);
+ this._prune(history[to], file.encoding);
+ history.groupedAt = 0;
+ return this._replace(file, text, size);
+ }
+
+ serialize() {
+ return JSON.stringify({format: 'visual-workspace', version: 1, name: this.name,
+ activePath: this.activePath, mainPath: this.mainPath, files: Array.from(this._files.values())});
+ }
+ static deserialize(serialized) {
+ const data = typeof serialized === 'string' ? JSON.parse(serialized) : serialized;
+ if (!data || data.format !== 'visual-workspace' || data.version !== 1) throw new Error('无法识别此项目文件版本');
+ return new ProjectModel(data);
+ }
+ references() {
+ const result = {labels: [], citations: [], bibliography: [], references: []};
+ for (const file of this._files.values()) {
+ if (file.kind !== 'text') continue;
+ const type = language(file.path);
+ if (type === 'latex') scanLatex(file.text, file.path, result);
+ if (type === 'bibtex') scanBibtex(file.text, file.path, result);
+ }
+ return result;
+ }
+ }
+
+ ProjectModel.MAX_FILES = MAX_FILES;
+ ProjectModel.MAX_TOTAL_BYTES = MAX_TOTAL_BYTES;
+ global.ProjectModel = ProjectModel;
+})(globalThis);
diff --git a/screenshots/studio-formula.jpg b/screenshots/studio-formula.jpg
new file mode 100644
index 0000000..3e49c9c
Binary files /dev/null and b/screenshots/studio-formula.jpg differ
diff --git a/screenshots/studio-workspace.jpg b/screenshots/studio-workspace.jpg
new file mode 100644
index 0000000..61c8f54
Binary files /dev/null and b/screenshots/studio-workspace.jpg differ
diff --git a/server.mjs b/server.mjs
new file mode 100644
index 0000000..54113e6
--- /dev/null
+++ b/server.mjs
@@ -0,0 +1,260 @@
+import http from 'node:http';
+import {randomBytes, randomUUID, timingSafeEqual} from 'node:crypto';
+import {readFile} from 'node:fs/promises';
+import {dirname, join, resolve} from 'node:path';
+import {fileURLToPath} from 'node:url';
+import {compileProject, discoverEngines} from './compiler.mjs';
+import {getPdfPreviewInfo, renderPdfPreviewPage} from './pdf-preview.mjs';
+
+const ROOT = dirname(fileURLToPath(import.meta.url));
+const MAX_BODY_BYTES = 80 * 1024 * 1024;
+const MAX_PDF_BYTES = 80 * 1024 * 1024;
+// The checked-in manifest is the only source of public vendor paths. Do not
+// expose a vendor directory or derive a filesystem path from the request URL.
+const mathliveManifest = JSON.parse(await readFile(new URL('./vendor/mathlive/manifest.json', import.meta.url), 'utf8'));
+const mathliveTypes = new Map([
+ ['mathlive.min.js', 'text/javascript; charset=utf-8'],
+ ['mathlive-static.css', 'text/css; charset=utf-8'],
+ ['mathlive-fonts.css', 'text/css; charset=utf-8']
+]);
+const mathliveAssets = mathliveManifest.files.flatMap(({path}) => {
+ const type = mathliveTypes.get(path) || (/^fonts\/[A-Za-z0-9_-]+\.woff2$/.test(path) ? 'font/woff2' : null);
+ return type ? [['/vendor/mathlive/' + path, ['vendor/mathlive/' + path, type]]] : [];
+});
+const ASSETS = new Map([
+ ['/', ['index.html', 'text/html; charset=utf-8']],
+ ['/index.html', ['index.html', 'text/html; charset=utf-8']],
+ ...['main.js', 'workspace.js', 'project-model.js', 'project-archive.js', 'preview.js',
+ 'visual-math.js', 'visual-references.js', 'visual-references-ui.js']
+ .map(name => ['/' + name, [name, 'text/javascript; charset=utf-8']]),
+ ...['style.css', 'workspace.css', 'preview.css', 'visual-math.css', 'visual-references-ui.css',
+ 'studio.css', 'studio-dialogs.css']
+ .map(name => ['/' + name, [name, 'text/css; charset=utf-8']]),
+ ...mathliveAssets
+]);
+
+function error(message, status = 400, code = 'INVALID_REQUEST') {
+ return Object.assign(new Error(message), {status, code});
+}
+
+function readJson(req, limit) {
+ if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) {
+ throw error('请求必须使用 JSON。', 415);
+ }
+ if (Number(req.headers['content-length']) > limit) throw error('项目请求超过 80 MB。', 413, 'REQUEST_TOO_LARGE');
+ return new Promise((resolveBody, reject) => {
+ const chunks = [];
+ let length = 0;
+ let settled = false;
+ const fail = problem => { if (!settled) { settled = true; chunks.length = 0; reject(problem); } };
+ req.on('data', chunk => {
+ if (settled) return;
+ length += chunk.length;
+ if (length > limit) { fail(error('项目请求超过 80 MB。', 413, 'REQUEST_TOO_LARGE')); return; }
+ chunks.push(chunk);
+ });
+ req.on('aborted', () => fail(error('请求已中断。')));
+ req.on('error', fail);
+ req.on('end', () => {
+ if (settled) return;
+ settled = true;
+ try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
+ catch { reject(error('项目 JSON 无效。')); }
+ });
+ });
+}
+
+/** Loopback-only application server; compilers run as this local OS user. */
+export function createWorkspaceServer({
+ compiler = compileProject, enginesProvider = discoverEngines, root = ROOT,
+ token = randomBytes(32).toString('hex'), maxBodyBytes = MAX_BODY_BYTES,
+ jobTTL = 10 * 60_000, maxJobs = 8, now = Date.now,
+ previewInfo = getPdfPreviewInfo, previewRenderer = renderPdfPreviewPage
+} = {}) {
+ const jobs = new Map();
+ const tokenBytes = Buffer.from(token);
+ let enginePromise;
+ let enginesAt = 0;
+ let previewActive = null;
+ const getEngines = () => {
+ if (!enginePromise || now() - enginesAt > 30_000) {
+ enginesAt = now();
+ enginePromise = Promise.resolve().then(() => enginesProvider()).catch(problem => { enginePromise = null; throw problem; });
+ }
+ return enginePromise;
+ };
+ function discard(job) {
+ if (previewActive?.job === job) previewActive.controller.abort();
+ jobs.delete(job.id);
+ }
+ function sweep() {
+ for (const job of jobs.values()) if (!job.active && now() - 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;
+ const disconnected = () => { if (!res.writableEnded) active.controller.abort(); };
+ res.once('close', disconnected);
+ try {
+ const engines = await getEngines();
+ if (active.controller.signal.aborted) throw error('预览已取消。', 409, 'PREVIEW_CANCELLED');
+ const value = await operation({engine: engines.postscript, signal: active.controller.signal});
+ if (active.controller.signal.aborted || jobs.get(job.id) !== job) throw error('编译结果已过期,请重新编译。', 404, 'JOB_NOT_FOUND');
+ return value;
+ } finally {
+ res.removeListener('close', disconnected);
+ if (previewActive === active) previewActive = null;
+ }
+ }
+ function authorised(req) {
+ const supplied = Buffer.from(String(req.headers['x-visuallatex-token'] || ''));
+ return supplied.length === tokenBytes.length && timingSafeEqual(supplied, tokenBytes);
+ }
+ const server = http.createServer(async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('X-Frame-Options', 'DENY');
+ res.setHeader('Referrer-Policy', 'no-referrer');
+ const json = (status, value) => {
+ if (res.destroyed || res.writableEnded) return;
+ res.writeHead(status, {'Content-Type': 'application/json; charset=utf-8'});
+ res.end(JSON.stringify(value));
+ };
+ try {
+ const port = server.address()?.port;
+ const host = req.headers.host;
+ if (![ '127.0.0.1:' + port, 'localhost:' + port ].includes(host)) throw error('不允许的本机服务地址。', 403, 'FORBIDDEN_HOST');
+ if ((req.headers.origin && req.headers.origin !== 'http://' + host) || req.headers['sec-fetch-site'] === 'cross-site') {
+ throw error('只允许工作台本身访问编译服务。', 403, 'FORBIDDEN_ORIGIN');
+ }
+ const url = new URL(req.url, 'http://' + host);
+ const path = url.pathname;
+ sweep();
+ if (path === '/api/status' && req.method === 'GET') {
+ const engines = await getEngines();
+ const publicEngine = value => ({available: Boolean(value?.available), engine: value?.engine || '', label: value?.label || ''});
+ json(200, {token, engines: {latex: publicEngine(engines.latex), postscript: publicEngine(engines.postscript)}});
+ return;
+ }
+ if (path.startsWith('/api/')) {
+ if (!authorised(req)) throw error('编译会话已失效,请刷新工作台。', 403, 'INVALID_TOKEN');
+ if (path === '/api/compile' && req.method === 'POST') {
+ if ([...jobs.values()].some(job => job.active)) throw error('已有编译任务正在运行,请等待或取消。', 409, 'COMPILER_BUSY');
+ const payload = await readJson(req, maxBodyBytes);
+ if (!payload || !['latex', 'postscript'].includes(payload.kind) || typeof payload.mainPath !== 'string' || !payload.project || typeof payload.project !== 'object') {
+ throw error('请选择 LaTeX 或 PostScript 主文件。');
+ }
+ // Recheck after the asynchronous upload to prevent two jobs
+ // from passing the concurrency check at the same time.
+ if ([...jobs.values()].some(job => job.active)) throw error('已有编译任务正在运行。', 409, 'COMPILER_BUSY');
+ while (jobs.size >= maxJobs) {
+ const oldest = [...jobs.values()].find(job => !job.active);
+ if (!oldest) throw error('编译服务繁忙。', 409, 'COMPILER_BUSY');
+ discard(oldest);
+ }
+ const job = {id: randomUUID(), state: 'running', active: true, log: '', mainPath: payload.mainPath,
+ controller: new AbortController(), startedAt: now(), finishedAt: null};
+ jobs.set(job.id, job);
+ let delivered = false;
+ res.once('finish', () => { delivered = true; });
+ res.once('close', () => {
+ if (!delivered && job.active) { job.state = 'cancelled'; job.controller.abort(); }
+ });
+ json(202, {id: job.id});
+ 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});
+ if (job.controller.signal.aborted) return;
+ if (!Buffer.isBuffer(result.pdf) || result.pdf.length > MAX_PDF_BYTES || result.pdf.subarray(0, 5).toString() !== '%PDF-') {
+ throw error('引擎没有生成有效 PDF,或结果超过 80 MB。', 500, 'INVALID_PDF');
+ }
+ Object.assign(job, {state: 'succeeded', pdf: result.pdf, log: String(result.log || '').slice(-200_000), engine: result.engine});
+ }).catch(problem => {
+ 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(); });
+ return;
+ }
+ const match = /^\/api\/jobs\/([0-9a-f-]+)(\/pdf|\/preview(?:\/([0-9]+))?)?$/.exec(path);
+ const job = match && jobs.get(match[1]);
+ if (!job) throw error('编译结果不存在或已过期,请重新编译。', 404, 'JOB_NOT_FOUND');
+ if (req.method === 'DELETE' && !match[2]) {
+ if (previewActive?.job === job) previewActive.controller.abort();
+ if (job.active) {
+ job.state = 'cancelled'; job.controller.abort();
+ }
+ json(200, {id: job.id, state: job.state});
+ return;
+ }
+ if (req.method !== 'GET') throw error('不支持的操作。', 405);
+ if (match[2] === '/pdf') {
+ if (job.state !== 'succeeded') throw error('PDF 尚未生成。', 409, 'PDF_NOT_READY');
+ res.writeHead(200, {'Content-Type': 'application/pdf', 'Content-Length': job.pdf.length,
+ 'Content-Disposition': 'inline; filename="preview.pdf"'});
+ res.end(job.pdf);
+ } else if (match[2]?.startsWith('/preview')) {
+ 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 (!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)) {
+ throw error('预览页码超出范围(最多预览前 100 页)。', 400, 'PREVIEW_PAGE_RANGE');
+ }
+ const png = job.previewCache?.page === page ? job.previewCache.png
+ : await previewOperation(job, res, options => previewRenderer(job.pdf, page, {...options, pageCount: info.pageCount}));
+ job.previewCache = {page, png};
+ if (res.destroyed || res.writableEnded) return;
+ 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'} : {})});
+ return;
+ }
+ if (!['GET', 'HEAD'].includes(req.method)) throw error('不支持的操作。', 405);
+ const asset = ASSETS.get(path);
+ if (!asset) throw error('文件不存在。', 404);
+ const bytes = await readFile(join(root, asset[0]));
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-src blob:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
+ res.writeHead(200, {'Content-Type': asset[1], 'Content-Length': bytes.length});
+ res.end(req.method === 'HEAD' ? undefined : bytes);
+ } catch (problem) {
+ json(problem.status || 500, {error: problem.status ? problem.message : '本地服务暂时不可用:' + problem.message, code: problem.code || 'SERVER_ERROR'});
+ }
+ });
+ server.requestTimeout = 30_000;
+ server.headersTimeout = 10_000;
+ const timer = setInterval(sweep, 30_000);
+ timer.unref();
+ const close = server.close.bind(server);
+ server.close = callback => {
+ previewActive?.controller.abort();
+ for (const job of jobs.values()) if (job.active) { job.state = 'cancelled'; job.controller.abort(); }
+ return close(callback);
+ };
+ server.on('close', () => {
+ clearInterval(timer);
+ for (const job of jobs.values()) if (job.active) { job.state = 'cancelled'; job.controller.abort(); }
+ jobs.clear();
+ });
+ return server;
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const argument = process.argv.indexOf('--port');
+ const port = Number(argument >= 0 ? process.argv[argument + 1] : process.env.VISUALLATEX_PORT || 4173);
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('端口必须在 0–65535 之间。');
+ const server = createWorkspaceServer();
+ server.on('error', problem => { console.error('无法启动工作台:' + problem.message); process.exitCode = 1; });
+ server.listen(port, '127.0.0.1', () => {
+ console.log('VisualLaTeX Studio: http://127.0.0.1:' + server.address().port);
+ console.log('浏览器打开上方地址。编译在本机进行;按 Ctrl+C 退出。');
+ });
+ for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
+}
diff --git a/setup-windows.ps1 b/setup-windows.ps1
new file mode 100644
index 0000000..9993ebd
--- /dev/null
+++ b/setup-windows.ps1
@@ -0,0 +1,94 @@
+# Download pinned official tools into this checkout; never run an installer.
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+if (-not [Environment]::Is64BitOperatingSystem) { throw 'The portable compilers require 64-bit Windows.' }
+[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
+$toolsRoot = Join-Path $PSScriptRoot '.tools'
+$downloads = Join-Path $toolsRoot 'downloads'
+foreach ($directory in @($toolsRoot, $downloads)) {
+ if ((Test-Path -LiteralPath $directory) -and ((Get-Item -LiteralPath $directory).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ throw "Tool directories cannot be symbolic links or junctions: $directory"
+ }
+}
+New-Item -ItemType Directory -Path $downloads -Force | Out-Null
+
+function Get-VerifiedDownload([string]$Name, [string]$Url, [string]$Sha256) {
+ $destination = Join-Path $downloads $Name
+ if (Test-Path -LiteralPath $destination -PathType Leaf) {
+ if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash -eq $Sha256) { return $destination }
+ throw "Existing download failed verification: $destination. Remove that download and retry."
+ }
+ $partial = $destination + '.' + [Guid]::NewGuid().ToString('N') + '.partial'
+ Write-Host "Downloading $Name from $Url"
+ Invoke-WebRequest -Uri $Url -OutFile $partial -UseBasicParsing -TimeoutSec 180
+ if ((Get-FileHash -LiteralPath $partial -Algorithm SHA256).Hash -ne $Sha256) {
+ throw "SHA-256 verification failed. The unused download remains at $partial."
+ }
+ Move-Item -LiteralPath $partial -Destination $destination
+ return $destination
+}
+
+function New-StagingDirectory([string]$Tool) {
+ $directory = Join-Path $toolsRoot ($Tool + '-staging-' + [Guid]::NewGuid().ToString('N'))
+ New-Item -ItemType Directory -Path $directory | Out-Null
+ return $directory
+}
+
+function Move-ToolDirectory([string]$Source, [string]$Destination) {
+ $resolvedRoot = (Resolve-Path -LiteralPath $toolsRoot).ProviderPath.TrimEnd([IO.Path]::DirectorySeparatorChar)
+ $resolvedSource = (Resolve-Path -LiteralPath $Source).ProviderPath
+ $resolvedDestination = [IO.Path]::GetFullPath($Destination)
+ $prefix = $resolvedRoot + [IO.Path]::DirectorySeparatorChar
+ if (-not $resolvedSource.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -or
+ -not $resolvedDestination.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -or
+ ((Get-Item -LiteralPath $resolvedSource).Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ (Test-Path -LiteralPath $resolvedDestination)) {
+ throw 'Refusing to move a tool directory outside .tools or replace an existing directory.'
+ }
+ Move-Item -LiteralPath $resolvedSource -Destination $resolvedDestination
+}
+
+# SHA-256 digests are published on these exact GitHub release assets.
+$tectonicRoot = Join-Path $toolsRoot 'tectonic'
+$tectonicExe = Join-Path $tectonicRoot 'tectonic.exe'
+if (-not (Test-Path -LiteralPath $tectonicExe -PathType Leaf)) {
+ if (Test-Path -LiteralPath $tectonicRoot) { throw "Incomplete tool directory already exists: $tectonicRoot. Rename it before retrying." }
+ $archive = Get-VerifiedDownload 'tectonic-0.17.0-windows-msvc.zip' 'https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%400.17.0/tectonic-0.17.0-x86_64-pc-windows-msvc.zip' 'f61ce51f0b0ade1015b7de7ef368541c5424e9756ecbd0d7af97d6d48030845f'
+ $staging = New-StagingDirectory 'tectonic'
+ Expand-Archive -LiteralPath $archive -DestinationPath $staging
+ if (-not (Test-Path -LiteralPath (Join-Path $staging 'tectonic.exe') -PathType Leaf)) { throw "Tectonic executable missing from $staging" }
+ Move-ToolDirectory $staging $tectonicRoot
+}
+
+$ghostscriptRoot = Join-Path $toolsRoot 'ghostscript'
+$ghostscriptExe = Join-Path $ghostscriptRoot 'bin\gswin64c.exe'
+if (-not (Test-Path -LiteralPath $ghostscriptExe -PathType Leaf)) {
+ if (Test-Path -LiteralPath $ghostscriptRoot) { throw "Incomplete tool directory already exists: $ghostscriptRoot. Rename it before retrying." }
+ $sevenRoot = Join-Path $toolsRoot '7zip'
+ $sevenExe = Join-Path $sevenRoot '7z.exe'
+ if (-not (Test-Path -LiteralPath $sevenExe -PathType Leaf)) {
+ if (Test-Path -LiteralPath $sevenRoot) { throw "Incomplete extraction tool directory: $sevenRoot. Rename it before retrying." }
+ $sevenReduced = Get-VerifiedDownload '7zr-26.05.exe' 'https://github.com/ip7z/7zip/releases/download/26.05/7zr.exe' '9bf582b894dfa665f852d7e9112b02290a1c303c2d95a21fcdc41ffcb03143b7'
+ $sevenArchive = Get-VerifiedDownload '7z2605-x64.exe' 'https://github.com/ip7z/7zip/releases/download/26.05/7z2605-x64.exe' 'fd99e86f57d8f429ef5ed045bf98315af962e2309b0f1ba87a147a44c71647d0'
+ $staging = New-StagingDirectory '7zip'
+ & $sevenReduced x $sevenArchive "-o$staging" -y | Out-Null
+ if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $staging '7z.exe') -PathType Leaf)) { throw "Unable to unpack 7-Zip. Staging files: $staging" }
+ Move-ToolDirectory $staging $sevenRoot
+ }
+ $archive = Get-VerifiedDownload 'gs10080w64.exe' 'https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/gs10080/gs10080w64.exe' '52a91b8bf09298788d7a57b9206127026c23eacd75405f0a131e26dc381dce50'
+ $staging = New-StagingDirectory 'ghostscript'
+ & $sevenExe x $archive "-o$staging" -y | Out-Null
+ if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $staging 'bin\gswin64c.exe') -PathType Leaf)) { throw "Unable to unpack Ghostscript. Staging files: $staging" }
+ Move-ToolDirectory $staging $ghostscriptRoot
+}
+
+New-Item -ItemType Directory -Path (Join-Path $toolsRoot 'cache') -Force | Out-Null
+& $tectonicExe --version
+if ($LASTEXITCODE -ne 0) { throw 'Tectonic could not start. Check the Microsoft Visual C++ runtime and the tool documentation.' }
+& $ghostscriptExe --version
+if ($LASTEXITCODE -ne 0) { throw 'Ghostscript could not start. Check the Microsoft Visual C++ runtime and the tool documentation.' }
+Write-Host 'Portable tools are ready in .tools. Run .\start.ps1 to open the local workspace service.'
+Write-Host 'Tectonic downloads missing TeX support files during the first compilation; project source is compiled locally.'
diff --git a/start.ps1 b/start.ps1
new file mode 100644
index 0000000..4fc4da0
--- /dev/null
+++ b/start.ps1
@@ -0,0 +1,21 @@
+[CmdletBinding()]
+param([ValidateRange(0, 65535)][int]$Port = 4173)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$nodeCommand = Get-Command node -ErrorAction SilentlyContinue
+if (-not $nodeCommand) { throw 'Node.js 22 or newer is required: https://nodejs.org/en/download' }
+$nodeVersion = & $nodeCommand.Source --version
+$versionMatch = [regex]::Match([string]$nodeVersion, '^v(\d+)\.')
+if ($LASTEXITCODE -ne 0 -or -not $versionMatch.Success -or [int]$versionMatch.Groups[1].Value -lt 22) { throw 'Please install Node.js 22 or newer.' }
+$toolsRoot = Join-Path $PSScriptRoot '.tools'
+$tectonicExe = Join-Path $toolsRoot 'tectonic\tectonic.exe'
+$ghostscriptExe = Join-Path $toolsRoot 'ghostscript\bin\gswin64c.exe'
+if (-not $env:VISUALLATEX_TECTONIC -and -not $env:VISUALLATEX_XELATEX -and (Test-Path -LiteralPath $tectonicExe -PathType Leaf)) { $env:VISUALLATEX_TECTONIC = $tectonicExe }
+if (-not $env:VISUALLATEX_GHOSTSCRIPT -and (Test-Path -LiteralPath $ghostscriptExe -PathType Leaf)) { $env:VISUALLATEX_GHOSTSCRIPT = $ghostscriptExe }
+if (-not $env:TECTONIC_CACHE_DIR) {
+ $env:TECTONIC_CACHE_DIR = Join-Path $toolsRoot 'cache'
+ New-Item -ItemType Directory -Path $env:TECTONIC_CACHE_DIR -Force | Out-Null
+}
+& $nodeCommand.Source (Join-Path $PSScriptRoot 'server.mjs') --port $Port
+exit $LASTEXITCODE
diff --git a/studio-dialogs.css b/studio-dialogs.css
new file mode 100644
index 0000000..7026dd2
--- /dev/null
+++ b/studio-dialogs.css
@@ -0,0 +1,236 @@
+/* The editing tools are small, focused work surfaces above the document. */
+.workspace-app .modal-overlay,
+.workspace-app .workspace-dialog-overlay {
+ padding: 24px;
+ background: rgb(28 34 53 / 36%);
+ backdrop-filter: blur(3px);
+ -webkit-backdrop-filter: blur(3px);
+}
+
+.workspace-app .modal-dialog,
+.workspace-app .workspace-dialog {
+ max-width: calc(100vw - 48px);
+ max-height: calc(100dvh - 48px);
+ border: 1px solid rgb(255 255 255 / 70%);
+ border-radius: 16px;
+ background: #fff;
+ color: #242733;
+ box-shadow: 0 24px 80px rgb(29 36 59 / 18%), 0 3px 12px rgb(29 36 59 / 8%);
+ font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
+ animation: studio-dialog-enter 150ms ease-out both;
+}
+
+.workspace-app .modal-dialog { width: 730px; max-width: min(730px, calc(100vw - 48px)); }
+.workspace-app .modal-header {
+ flex: 0 0 auto;
+ min-height: 76px;
+ padding: 20px 26px;
+ border-bottom: 1px solid #e1e5ee;
+ background: #fff;
+ color: #242733;
+}
+.workspace-app .modal-title { font-size: 19px; font-weight: 650; letter-spacing: -.3px; }
+.workspace-app .modal-title::before {
+ content: "ƒ";
+ display: inline-grid;
+ place-items: center;
+ width: 33px;
+ height: 33px;
+ margin-right: 11px;
+ border-radius: 9px;
+ background: #eef0ff;
+ color: #4b5fdb;
+ font: italic 26px/1 Cambria, Georgia, serif;
+ vertical-align: middle;
+}
+.workspace-app .modal-close {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 34px;
+ min-height: 34px;
+ height: 34px;
+ padding: 0;
+ border: 0;
+ border-radius: 8px;
+ background: #f3f4f7;
+ color: #737b8c;
+ font-size: 23px;
+ font-weight: 400;
+ line-height: 1;
+}
+.workspace-app .modal-close:hover { background: #e9ecf4; color: #242733; }
+.workspace-app .modal-body { padding: 22px 26px 24px; overscroll-behavior: contain; }
+
+.workspace-app .formula-mode-row { gap: 8px; margin: 0 0 16px; }
+.workspace-app .mode-option {
+ gap: 7px;
+ padding: 7px 11px;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ background: #f3f4f7;
+ color: #737b8c;
+ font-size: 12px;
+ line-height: 1.5;
+}
+.workspace-app .mode-option:has(input:checked) { border-color: #dce1fb; background: #eef0ff; color: #3f51bc; }
+.workspace-app .mode-option input { margin: 0; accent-color: #4b5fdb; }
+.workspace-app .mode-option:has(input:disabled) { cursor: default; }
+.workspace-app .symbol-palette {
+ gap: 5px;
+ max-height: 148px;
+ margin: 0 0 18px;
+ padding: 9px;
+ border: 1px solid #e1e5ee;
+ border-radius: 10px;
+ background: #f8f9fc;
+ scrollbar-width: thin;
+}
+.workspace-app .symbol-palette button {
+ min-width: 33px;
+ min-height: 32px;
+ padding: 5px 9px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: #fff;
+ color: #485064;
+ box-shadow: 0 1px 2px rgb(32 39 58 / 5%);
+ font: 12px/1.5 "Segoe UI", "Microsoft YaHei", sans-serif;
+}
+.workspace-app .symbol-palette button:hover {
+ border-color: #dce1fb;
+ background: #eef0ff;
+ color: #3f51bc;
+}
+.workspace-app #formulaMathField {
+ min-height: 120px;
+ padding: 23px 20px;
+ border: 1px solid #d7dce8;
+ border-radius: 11px;
+ background: #fff;
+ color: #242733;
+ font-size: 29px;
+ outline: 0;
+ box-shadow: inset 0 1px 3px rgb(35 42 61 / 3%);
+ --caret-color: #4b5fdb;
+ --selection-background-color: #e0e5ff;
+ --selection-color: #242733;
+}
+.workspace-app #formulaMathField:focus,
+.workspace-app #formulaMathField:focus-visible,
+.workspace-app #formulaMathField:focus-within {
+ border-color: #6575df;
+ outline: 0;
+ box-shadow: 0 0 0 3px rgb(75 95 219 / 11%);
+}
+.workspace-app #formulaMathField::part(virtual-keyboard-toggle) { color: #737b8c; border-radius: 6px; }
+.workspace-app .formula-help { margin: 11px 1px 16px; color: #737b8c; font-size: 12px; line-height: 1.75; }
+.workspace-app #formulaMetadata { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
+.workspace-app #formulaMetadata label { min-width: 0; margin: 0 0 12px; color: #555e71; font-size: 12px; font-weight: 550; line-height: 1.6; }
+.workspace-app #formulaMetadata label:only-child { grid-column: 1 / -1; }
+.workspace-app #formulaMetadata input,
+.workspace-app .workspace-dialog input,
+.workspace-app .workspace-dialog select,
+.workspace-app #formulaInput {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 42px;
+ padding: 10px 12px;
+ border: 1px solid #d7dce8;
+ border-radius: 8px;
+ background: #fff;
+ color: #242733;
+ box-shadow: inset 0 1px 2px rgb(35 42 61 / 2%);
+ font: 13px/1.6 "Cascadia Code", Consolas, monospace;
+}
+.workspace-app #formulaMetadata input { margin-top: 7px; }
+.workspace-app .workspace-dialog select { padding-right: 28px; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; }
+.workspace-app .workspace-dialog input:focus-visible,
+.workspace-app .workspace-dialog select:focus-visible,
+.workspace-app #formulaMetadata input:focus-visible,
+.workspace-app #formulaInput:focus-visible {
+ border-color: #6575df;
+ outline: 0;
+ box-shadow: 0 0 0 3px rgb(75 95 219 / 11%);
+}
+.workspace-app .formula-advanced { margin-top: 14px; color: #737b8c; font-size: 12px; }
+.workspace-app .formula-advanced summary { width: fit-content; padding: 3px 0; cursor: pointer; }
+.workspace-app .formula-advanced summary:hover { color: #4b5fdb; }
+.workspace-app #formulaInput { min-height: 104px; margin-top: 12px; background: #f8f9fc; resize: vertical; }
+.workspace-app #formulaError,
+.workspace-app .reference-dialog .reference-error { margin: 9px 0 0; min-height: 0; color: #a83f4a; font-size: 12px; line-height: 1.7; }
+.workspace-app #formulaError:empty,
+.workspace-app .reference-dialog .reference-error:empty { margin: 0; }
+.workspace-app .reference-dialog input[aria-invalid="true"] { border-color: #b95c65; }
+
+.workspace-app .modal-actions,
+.workspace-app .workspace-dialog-actions { gap: 9px; margin-top: 22px; padding-top: 18px; border-top: 1px solid #e1e5ee; }
+.workspace-app .modal-dialog > .modal-actions { flex: 0 0 auto; align-items: center; justify-content: space-between; margin: 0; padding: 16px 26px; background: white; }
+.workspace-app .formula-shortcut { color: #949cac; font: 10px/1.5 "Segoe UI", "Microsoft YaHei", sans-serif; }
+.workspace-app .modal-actions button,
+.workspace-app .workspace-dialog-actions button {
+ min-height: 40px;
+ padding: 9px 20px;
+ border: 1px solid #dce1eb;
+ border-radius: 8px;
+ background: #fff;
+ color: #555e71;
+ font-size: 13px;
+ font-weight: 550;
+}
+.workspace-app .modal-actions button:hover,
+.workspace-app .workspace-dialog-actions button:hover { border-color: #cbd1e0; background: #f3f4f7; }
+.workspace-app .modal-actions #btnInsertFormula,
+.workspace-app .workspace-dialog-actions #referenceAccept,
+.workspace-app .workspace-dialog-actions #workspaceDialogAccept {
+ border-color: #4b5fdb;
+ background: #4b5fdb;
+ color: #fff;
+ box-shadow: 0 2px 3px rgb(75 95 219 / 12%);
+}
+.workspace-app .modal-actions #btnInsertFormula:hover,
+.workspace-app .workspace-dialog-actions #referenceAccept:hover,
+.workspace-app .workspace-dialog-actions #workspaceDialogAccept:hover { border-color: #3f51bc; background: #3f51bc; }
+
+.workspace-app .workspace-dialog { width: min(480px, 100%); overflow-y: auto; overscroll-behavior: contain; padding: 28px; }
+.workspace-app .workspace-dialog.reference-dialog { width: min(560px, 100%); }
+.workspace-app .workspace-dialog h2 { margin: 0 0 11px; color: #242733; font-size: 20px; font-weight: 650; letter-spacing: -.4px; }
+.workspace-app .workspace-dialog label { margin: 18px 0 8px; color: #555e71; font-size: 12px; font-weight: 550; line-height: 1.7; }
+.workspace-app .workspace-dialog .reference-hint { margin: 0 0 24px; color: #737b8c; font-size: 13px; line-height: 1.8; }
+.workspace-app .workspace-dialog #workspaceDialogMessage { margin: 0 0 18px; color: #737b8c; font-size: 13px; font-weight: 400; }
+.workspace-app .workspace-dialog select:disabled { background: #f3f4f7; color: #737b8c; }
+.workspace-app .modal-dialog :is(button, summary):focus-visible,
+.workspace-app .workspace-dialog button:focus-visible { outline: 2px solid #4b5fdb; outline-offset: 3px; }
+
+@keyframes studio-dialog-enter {
+ from { opacity: .65; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@media (max-width: 600px) {
+ .workspace-app .modal-overlay,
+ .workspace-app .workspace-dialog-overlay { padding: 12px; }
+ .workspace-app .modal-overlay { align-items: center; }
+ .workspace-app .modal-dialog,
+ .workspace-app .workspace-dialog { max-width: calc(100vw - 24px); max-height: calc(100dvh - 24px); border-radius: 14px; }
+ .workspace-app .modal-header { min-height: 66px; padding: 14px 18px; }
+ .workspace-app .modal-title { font-size: 17px; }
+ .workspace-app .modal-body { padding: 18px; }
+ .workspace-app .modal-dialog > .modal-actions { padding: 12px 18px; }
+ .workspace-app .formula-shortcut { max-width: 110px; font-size: 9px; }
+ .workspace-app .modal-close { width: 40px; height: 40px; min-height: 40px; }
+ .workspace-app .symbol-palette { max-height: 158px; padding: 7px; gap: 5px; }
+ .workspace-app .symbol-palette button { min-width: 39px; min-height: 40px; padding: 7px 10px; }
+ .workspace-app #formulaMathField { min-height: 110px; padding: 20px 14px; font-size: 25px; }
+ .workspace-app #formulaMetadata { grid-template-columns: minmax(0, 1fr); gap: 0; }
+ .workspace-app .workspace-dialog { padding: 23px; }
+ .workspace-app #formulaMetadata input,
+ .workspace-app .workspace-dialog input,
+ .workspace-app .workspace-dialog select,
+ .workspace-app #formulaInput { min-height: 44px; font-size: 16px; }
+ .workspace-app .modal-actions button,
+ .workspace-app .workspace-dialog-actions button { min-height: 44px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .workspace-app .modal-dialog,
+ .workspace-app .workspace-dialog { animation: none; }
+}
diff --git a/studio.css b/studio.css
new file mode 100644
index 0000000..10cebb4
--- /dev/null
+++ b/studio.css
@@ -0,0 +1,202 @@
+/* Studio chrome: a quiet drafting desk around a typeset, editable manuscript. */
+.workspace-app {
+ --bg: #f3f4f7; --surface: #fff; --surface-soft: #f8f9fc;
+ --text: #242733; --text-secondary: #737b8c; --border: #e1e5ee;
+ --border-strong: #cbd1df; --accent: #4b5fdb; --accent-hover: #394dc2;
+ --accent-soft: #eef0ff; --ink-dark: #242733; --radius: 7px;
+ --ui-font: "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
+ --paper-font: Cambria, Georgia, "Noto Serif CJK SC", SimSun, serif;
+ font-family: var(--ui-font); -webkit-font-smoothing: antialiased;
+}
+.workspace-app .container {
+ display: grid; grid-template-columns: minmax(0,1fr) auto;
+ grid-template-rows: 68px 54px minmax(0,1fr) 27px;
+}
+.workspace-app button { font-size: 12px; font-weight: 500; border-color: var(--border); border-radius: 7px; box-shadow: none; }
+.workspace-app button:hover { border-color: #ccd3ea; background: #f0f2fa; }
+.workspace-app button:disabled { opacity: .36; }
+.workspace-app :is(button,input,select,textarea,[contenteditable]):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+.workspace-app button.primary { background: var(--accent); border-color: var(--accent); box-shadow: 0 2px 5px #4b5fdb24; }
+.workspace-app button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
+.ui-icon { width: 17px; height: 17px; flex: 0 0 17px; vertical-align: middle; pointer-events: none; }
+.workspace-app .icon-button { display: inline-flex; justify-content: center; align-items: center; gap: 7px; }
+.workspace-app .icon-only .button-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
+
+/* Project context is distinct from the document's editing tools. */
+.workspace-header { grid-column: 1/-1; min-height: 0; padding: 0 24px; gap: 28px; border-bottom: 1px solid var(--border); }
+.brand-block { gap: 10px; min-width: 192px; }
+.brand-monogram { width: 33px; height: 37px; display: grid; place-items: center; border-radius: 9px; background: var(--accent); color: white; font: italic 28px/.9 Cambria,Georgia,serif; box-shadow: 0 3px 7px #4b5fdb1c; }
+.brand-block h1 { font: 600 18px/1.15 var(--ui-font); letter-spacing: -.75px; }
+.brand-block h1 span { font: 9px/1 var(--ui-font); color: #8a91a2; letter-spacing: 2.4px; margin-top: 5px; }
+.language-signature { display: none; }
+.project-identity { max-width: 360px; padding-left: 24px; border-left: 1px solid var(--border); gap: 9px; }
+.project-identity label { font-size: 11px; color: #8a91a2; }
+.project-identity input { min-height: 34px; padding: 5px 8px !important; background: transparent !important; border: 1px solid transparent !important; font-weight: 500 !important; }
+.project-identity input:hover { background: var(--surface-soft) !important; }
+.project-identity input:focus { background: var(--surface-soft) !important; border-color: var(--border) !important; }
+.project-actions { gap: 8px; }
+.project-actions button { min-height: 34px; padding: 6px 11px; background: transparent; border-color: transparent; color: #586176; }
+.workspace-app #btnExportProject { border-color: var(--border); background: white; color: var(--text); box-shadow: none; }
+
+/* One compact command row, with compilation as the single primary action. */
+.workspace-toolbar.toolbar { min-width: 0; margin: 0; padding: 0 16px; gap: 10px; background: white; border-bottom: 1px solid var(--border); overflow: auto; scrollbar-width: thin; }
+.workspace-toolbar .tool-group { gap: 3px; flex-shrink: 0; }
+.workspace-toolbar.toolbar button { min-height: 32px; padding: 5px 7px; border-color: transparent; background: transparent; border-radius: 6px; font-size: 12px; }
+.workspace-toolbar.toolbar button:hover { background: var(--accent-soft); color: var(--accent); }
+.workspace-toolbar.toolbar .icon-only { width: 32px; padding: 5px; }
+.workspace-toolbar .separator { height: 19px; background: var(--border); margin: 0; }
+.workspace-toolbar :is(#btnH1,#btnH2,#btnH3) { font-family: Cambria,Georgia,serif; font-weight: 600; font-size: 15px; }
+.workspace-toolbar :is(#btnBold,#btnItalic) { font-family: Georgia,serif; font-size: 15px; width: 30px; }
+.workspace-toolbar :is(#btnInlineFormula,#btnBlockFormula) { font-size: 12px; }
+.math-tool-sign { font: italic 18px Cambria,Georgia,serif; margin-right: 4px; color: #515f82; }
+.compile-toolbar { min-width: 0; flex-wrap: nowrap; gap: 6px; padding: 0 18px 0 10px; background: white; border-bottom: 1px solid var(--border); }
+.compile-toolbar > label { display: none; }
+.workspace-app .compile-toolbar select { width: 118px; min-width: 0; max-width: 150px; height: 32px; font-size: 11px; padding-left: 8px; background: var(--surface-soft); border-color: transparent; }
+.compile-toolbar button { min-height: 32px; padding: 5px 11px; }
+#btnCompile::before { content: ""; display: inline-block; vertical-align: -1px; margin-right: 7px; width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 7px solid currentColor; }
+.compiler-status { display: none; }
+.compile-toolbar:has(#btnCompile:disabled):not(:has(#btnCancelCompile:not([hidden]))) .compiler-status {
+ display: inline; max-width: 120px; color: #8b624a; font-size: 10px; margin-left: 2px;
+}
+.workspace-app #btnCompilerRetry { min-height: 30px; width: 28px; padding: 5px; }
+.workspace-app #btnTogglePreview { width: 32px; padding: 6px; border-color: transparent; }
+.workspace-app #btnTogglePreview[aria-expanded="true"] { background: var(--accent-soft); color: var(--accent); }
+
+/* File navigation and reference tools remain secondary to the manuscript. */
+.workbench.main-content { grid-column: 1/-1; grid-template-columns: 218px minmax(0,1fr) 236px; }
+.project-sidebar, .context-sidebar { background: #fafbfe; }
+.sidebar-heading { min-height: 58px; padding: 16px 17px 10px; }
+.sidebar-heading h2 { color: #555e72; font-size: 12px; font-weight: 600; letter-spacing: .5px; }
+.workspace-app .small-button { font-size: 12px; color: var(--text-secondary); }
+.file-import-actions { padding: 0 14px 15px; gap: 7px; }
+.file-import-actions button { min-height: 32px; background: white; font-size: 11px; padding: 5px 7px; }
+.file-tree { padding: 8px 10px 16px; border-top: 1px solid #edf0f6; border-bottom: 0; }
+.file-tree button { min-height: 40px; padding: 10px 11px; border: 0; border-radius: 7px; color: #60697c; font: 12px/1.6 var(--editor-font); margin: 2px 0; }
+.file-tree button::before { content: ""; width: 11px; height: 14px; border: 1px solid #a3adbf; border-radius: 2px; flex: 0 0 11px; background: linear-gradient(#a3adbf,#a3adbf) 2px 8px/6px 1px no-repeat; }
+.file-tree button[title="bibtex"]::before { border-color: #8998a5; background: linear-gradient(90deg, transparent 4px,#8998a5 4px,#8998a5 5px,transparent 5px); }
+.file-tree button.active, .file-tree button[aria-current="true"], .file-tree button[aria-selected="true"] { background: #eaeefe; border: 0; box-shadow: none; color: #3f50bd; font-weight: 600; }
+.file-tree button.active::before { border-color: #6e7cce; }
+.file-actions { gap: 5px; padding: 12px 13px 2px; border-top: 1px solid var(--border); }
+.file-actions button { min-height: 28px; font-size: 10px; padding: 4px 6px; background: transparent; border-color: transparent; color: #6d7588; }
+.file-actions button:hover { background: #eceff7; }
+.sidebar-note { padding: 10px 17px 17px; font-size: 11px; line-height: 1.8; color: #737e91; }
+.context-sidebar { background: white; }
+.context-list { padding: 0 12px 15px; font-size: 12px; }
+.context-list button { padding: 8px 9px; min-height: 35px; font-size: 12px; font-weight: 400; color: #687186; border: 1px solid transparent; background: transparent; border-radius: 6px; line-height: 1.6; }
+.outline-section { min-height: 120px; flex-basis: 29%; }
+.outline-section .context-list button { border-left: 2px solid #e7eaf3; border-radius: 0; padding-left: 13px; margin-left: 5px; width: calc(100% - 5px); color: #414b62; }
+.reference-actions { gap: 6px; padding: 0 16px 15px; }
+.reference-actions button { min-height: 32px; font-size: 11px; border-color: var(--border); background: white; }
+.reference-actions button:hover { background: var(--accent-soft); color: var(--accent); }
+
+/* Paper, not a second application panel. No decoration is inserted in the DOM. */
+.document-header { min-height: 59px; padding: 11px 24px; background: #fbfcfe; border-bottom: 1px solid var(--border); }
+.document-location { gap: 9px; }
+.current-path { font-size: 12px; color: #525d74; }
+.language-badge { border: 1px solid #e4e7ef; background: white; color: #8390aa; font: 9px var(--editor-font); padding: 3px 5px; }
+.mode-switch { padding: 3px; gap: 0; border: 0; border-radius: 8px; background: #eceff5; }
+.mode-switch button { min-height: 30px; padding: 5px 13px; font-size: 11px; border-radius: 6px; }
+.mode-switch button.active, .mode-switch button[aria-pressed="true"] { background: white; color: #354570; box-shadow: 0 1px 4px #30395812; }
+.visual-panel { background: var(--bg); }
+.mode-note { padding: 13px 24px 0; text-align: center; background: transparent; border: 0; color: #737f94; font-size: 11px; }
+.mode-note::before { content: ""; width: 4px; height: 4px; border-radius: 50%; display: inline-block; background: #abb5d5; vertical-align: 2px; margin-right: 7px; }
+.workspace-app .editor-area { width: min(calc(100% - 48px),820px); margin: 18px auto 24px; padding: 48px 54px 90px; border: 1px solid #e4e7ee; border-radius: 2px; background: white; box-shadow: 0 4px 24px #252d4410; color: #292e39; font: 17px/1.95 var(--paper-font); caret-color: var(--accent); scrollbar-width: thin; }
+.workspace-app .editor-area:focus { outline: none; background: white; border-color: #d5dbe9; box-shadow: 0 4px 24px #252d4410,0 0 0 1px #dfe4f0; }
+.workspace-app .editor-area:focus-visible { outline: 2px solid #bdc7f0; outline-offset: 3px; }
+.workspace-app .editor-area h1 { margin: 0 0 24px; padding: 0; border: 0; color: #252a37; font: 600 30px/1.35 var(--paper-font); letter-spacing: -.6px; }
+.workspace-app .editor-area h2 { margin: 29px 0 15px; padding: 0; border: 0; color: #303746; font: 600 24px/1.45 var(--paper-font); }
+.workspace-app .editor-area h3 { margin: 24px 0 12px; color: #424a5c; font: 600 20px/1.5 var(--paper-font); }
+.workspace-app .editor-area p { margin: 12px 0 18px; text-align: left; }
+.workspace-app .editor-area .formula-inline { padding: 1px 4px; background: transparent; border-color: transparent; color: inherit; }
+.workspace-app .editor-area .formula-block { position: relative; margin: 27px 0; padding: 18px 24px 15px; background: #fcfcfe; border: 1px solid #f0f2f7; border-radius: 7px; }
+.workspace-app .editor-area :is(.formula-inline,.formula-block):hover { background: #f3f5ff; border-color: #d3daf6; }
+.workspace-app .editor-area :is(.formula-inline,.formula-block):focus-visible { border-color: var(--accent); outline: 2px solid #d9dff8; }
+.workspace-app .visual-math-label { margin-top: 12px; color: #737f94; font: 10px/1.5 var(--editor-font); }
+.workspace-app .editor-area .tex-cmd[data-visual-reference] { max-width: 100%; vertical-align: baseline; padding: 0 3px; border: 0; border-bottom: 1px solid #ccd3ef; border-radius: 3px 3px 0 0; background: transparent; color: #5265ad; font: inherit; font-size: .88em; line-height: 1.65; }
+.workspace-app .editor-area .tex-cmd[data-visual-reference]:hover { background: #eef1ff; color: #3e51b1; filter: none; }
+.workspace-app .editor-area .tex-cmd[data-visual-reference="label"] { color: #65718a; border: 1px solid #e7eaf1; border-radius: 4px; padding: 1px 5px; font: 10px/1.8 var(--editor-font); background: #fcfcfe; }
+.workspace-app .editor-area .tex-cmd:is([data-visual-reference="include"],[data-visual-reference="bibliography"],[data-visual-reference="style"]) { color: #79859d; border-color: #dce1ec; font-size: .77em; }
+.document-footer { min-height: 25px; padding: 3px 20px; font-size: 10px; background: #f3f4f7; border-top: 0; color: #9199aa; }
+.source-hint { display: none; }
+.workspace-status.status-bar { grid-column: 1/-1; min-height: 0; padding: 4px 18px; background: #fff; border-top: 1px solid var(--border); color: #8b93a3; font-size: 10px; }
+.workspace-status #saveState { color: #78849a; }
+.line-numbers { background: #f9fafd; color: #a3adbf; border-color: #edf0f5; font-size: 13px; }
+.workspace-app .source-area { color: #39435a; font-size: 14px; line-height: 1.8; padding-top: 22px; }
+.find-panel { background: #fbfcff; box-shadow: inset 2px 0 var(--accent); }
+
+/* The PDF proof shares the same desk and paper treatment. */
+.compilation-visible .workbench.main-content { grid-template-columns: 184px minmax(280px,1fr) minmax(320px,44%); }
+.compilation-visible .document-header { padding: 10px 16px; }
+.compilation-visible .editor-area { width: calc(100% - 32px); padding-left: 30px; padding-right: 30px; }
+.pdf-preview-pane { background: var(--bg); border-color: var(--border); }
+.pdf-preview-header { background: #fbfcfe; min-height: 59px; padding: 11px 18px; }
+.pdf-preview-header h2 { color: #3f4a61; font-size: 12px; }
+.pdf-edit-note { background: transparent; color: #919aaa; font-size: 10px; padding: 12px 18px 5px; }
+.compile-status { background: transparent; color: #7d879b; font-size: 10px; padding: 4px 18px 10px; }
+.preview-page-controls { border: 0; background: transparent; padding: 0 10px 10px; }
+.preview-page-controls button { background: white; font-size: 10px; }
+.pdf-page-view { padding: 8px 22px 22px; }
+.pdf-page-view img { box-shadow: 0 4px 24px #252d4410; border: 1px solid #e4e7ee; }
+.pdf-open { background: transparent; text-decoration: none; color: #8793ab; padding: 7px 18px; }
+.compile-logs { background: #fbfcfe; border-color: var(--border); }
+.compile-logs summary { font-size: 11px; color: #6c7790; }
+
+@media (max-width: 1250px) {
+ .workspace-app .container { grid-template-columns: minmax(0,1fr); grid-template-rows: 62px 46px 46px minmax(0,1fr) 26px; }
+ .workspace-header { padding: 0 18px; }
+ .workspace-toolbar.toolbar { justify-content: center; }
+ .compile-toolbar { padding: 0 18px; justify-content: flex-end; background: #fbfcfe; }
+ .compile-toolbar > label { display: block; margin-right: auto; font-size: 10px; color: #939bad; }
+ .compiler-status { display: inline; margin-left: 5px; font-size: 10px; }
+ .workbench.main-content { grid-template-columns: 190px minmax(0,1fr) 216px; }
+ .workspace-app .editor-area { padding: 36px 38px 70px; width: calc(100% - 36px); }
+ .project-identity { padding-left: 15px; }
+}
+@media (max-width: 1100px) {
+ .workbench.main-content { grid-template-columns: 190px minmax(0,1fr); }
+ .context-sidebar { display: none; }
+ .outline-open .context-sidebar { display: flex; width: 264px; border-left: 1px solid var(--border); box-shadow: -12px 0 40px #303d5a12; }
+ .compilation-visible .workbench.main-content { grid-template-columns: minmax(260px,1fr) minmax(320px,49%); }
+ .compilation-visible .project-sidebar { display: none; }
+ .compilation-visible.files-open .project-sidebar { display: flex; }
+ .brand-block { min-width: 177px; }
+}
+@media (max-width: 700px) {
+ .workspace-app .container { grid-template-rows: auto 47px auto minmax(0,1fr) 26px; }
+ .workspace-header { padding: 13px 14px 10px; gap: 12px 14px; flex-wrap: wrap; }
+ .brand-block { min-width: 0; gap: 8px; }
+ .brand-monogram { width: 28px; height: 31px; font-size: 24px; border-radius: 7px; }
+ .brand-block h1 { font-size: 15px; }
+ .brand-block h1 span { font-size: 7px; letter-spacing: 2px; }
+ .project-identity { padding: 0; border: 0; flex: 1; min-width: 90px; max-width: 155px; margin-left: auto; }
+ .project-identity input { font-size: 13px !important; min-height: 34px; }
+ .project-actions { width: 100%; justify-content: flex-end; gap: 6px; }
+ .project-actions button { flex: 1; min-height: 33px; font-size: 11px; background: #f7f8fc; border-color: #edf0f6; }
+ .workspace-toolbar.toolbar { justify-content: flex-start; padding: 0 10px; gap: 8px; }
+ .workspace-toolbar.toolbar button { min-height: 36px; }
+ .workspace-toolbar.toolbar .icon-only { width: 34px; }
+ #btnFiles { display: inline-flex; }
+ .compile-toolbar { flex-wrap: wrap; padding: 8px 10px; gap: 6px; justify-content: flex-start; }
+ .compile-toolbar > label, .compiler-status { display: none; }
+ .workspace-app .compile-toolbar select { flex: 1 1 95px; max-width: none; min-width: 60px; height: 35px; font-size: 11px; }
+ .compile-toolbar button { min-height: 35px; padding: 5px 9px; font-size: 11px; }
+ .workspace-app #btnCompilerRetry { width: 24px; padding: 4px; }
+ .workbench.main-content, .compilation-visible .workbench.main-content { grid-template-columns: minmax(0,1fr); }
+ .project-sidebar { display: none; }
+ .files-open .project-sidebar { display: flex; }
+ .document-header { padding: 10px 12px; min-height: 55px; gap: 6px; }
+ .document-location { gap: 6px; flex-basis: 100px; }
+ .mode-switch button { padding: 6px 9px; min-height: 33px; font-size: 11px; }
+ .language-badge { display: none; }
+ .mode-note { padding: 10px 12px 0; font-size: 9px; }
+ .workspace-app .editor-area { width: calc(100% - 24px); padding: 30px 24px 60px; margin: 12px auto; font-size: 16px; line-height: 1.9; }
+ .workspace-app .editor-area h1 { font-size: 26px; }
+ .workspace-app .editor-area .formula-block { padding: 13px 10px; margin: 20px 0; }
+ .source-panel .source-area, .line-numbers { font-size: 16px; }
+ .file-tree button, .context-list button { min-height: 40px; }
+ .compilation-visible .document-pane { display: none; }
+ .pdf-preview-header { min-height: 53px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .workspace-app *, .workspace-app *::before, .workspace-app *::after { transition: none !important; animation: none !important; }
+}
diff --git a/tests/browser-acceptance.md b/tests/browser-acceptance.md
new file mode 100644
index 0000000..a3f602c
--- /dev/null
+++ b/tests/browser-acceptance.md
@@ -0,0 +1,27 @@
+# 真实浏览器验收清单
+
+使用单独的测试项目和本机 `node server.mjs`,先导出需要保留的旧项目。常规 `node --test` 不替代这里的真实浏览器与工具链验证。
+
+1. **默认可视化编辑**:打开完整 LaTeX 文档应直接显示可编辑纸面。直接修改标题、正文,用工具栏加粗及调整标题级别,双击现有公式修改并用 Ctrl/Cmd+Enter 确认;应保持编辑区,不能误触发编译。切到其他文件再返回应保留改动和明确选定的模式;刷新仍默认打开可视化。
+2. **完整编辑**:修改项目名称,立即切换文件、编辑,再刷新。名称和源码应恢复。新建 `sections/method.tex`,输入章节与 `\label`;新建/重命名弹窗的确认与取消都应工作,即使宿主不支持原生 `prompt`。
+2. **查找与历史**:替换章节中的一句话,撤销后恢复原文,重做后恢复新文。切换其他文件不应混用历史。
+3. **多文件与引用**:主文档用 `\input{sections/method}`、`\ref` 和 `\cite`,参考文献写入 `.bib`。切换可视化再返回源码,导言区和引用命令应保留。
+4. **主文件**:选择 `.ps` 并设为主文件,编译目标应同步;手动更改编译目标后,普通编辑不应重置目标。对原主文件再次点击「设为主文件」仍应切回该文件。
+5. **LaTeX 编译与下载**:编译主文档,检查实际页面文字、中文、编号和文献。导出 PDF 后用 PDF 阅读工具确认页数与文本,不能只检查 HTTP 成功状态。
+6. **修改后导出**:先编译成功,再修改章节文本。预览应显示旧结果提示;直接点击「导出 PDF」应先重新编译,下载文件须包含新文本。
+7. **PostScript**:输入两页 PS,分别绘制矩形和圆形并写入不同页标题。渲染后使用下一页和上一页,页码、图片与标题均须一致;导出 PDF 必须包含两页。
+8. **错误与取消**:缺少 `\input` 文件时应显示编译错误与日志,源码仍可改;输入 `%!PS\n{} loop` 后取消,随后恢复有效源码应能再次编译。失败或取消时,上次结果必须标明过期。
+9. **项目往返**:导出 ZIP,临时修改项目名,再打开刚导出的 ZIP 并确认替换。核对文件、路径、源码、项目名和主文件均恢复。
+10. **布局与生命周期**:在桌面双栏和窄屏中检查编辑/预览切换;刷新、离开页面再返回后,编辑和编译应继续工作。查看控制台是否存在未处理异常。
+
+PDF 图片预览依赖 Ghostscript,不依赖浏览器内置 PDF 插件。还需分别测试缺失 Ghostscript 的提示和 PDF 导出、服务不可用、图片渲染失败/重试;这些边界也有自动回归测试。
+
+本次验收环境:Windows、Chromium 内嵌浏览器、Tectonic 0.17.0、Ghostscript 10.08.0。中文样例使用 `ctex` 的 `fontset=windows`。这不代表已覆盖 Safari、Firefox、移动设备或真实 XeLaTeX 发行版。
+
+可视化恢复验收:实际在纸面修改标题、正文、粗体、标题级别与公式,下载的 `.tex` 包含全部改动且导言区/尾注及引用命令保持原样;再编译并读取实际下载 PDF,确认修改进入排版结果。当前用户项目在独立测试前后逐文件比较,内容不变。窄屏从 PDF 预览选择文档会返回编辑区。
+
+公式和引用专项验收:单击公式打开 MathLive,通过分数按钮及分子/分母占位框将公式改为 1/2;修改 equation 内容及标签,修改两行 align 的末行内容及标签,确认光标默认位于最后一格内。直接修改正文标签、选择交叉引用目标、替换两处相同文献引用中的第二处,全程不切源码。验证取消、撤销、重做、刷新恢复和长引用卡片点击。实际下载 TeX 保留导言区和尾注;编译 PDF 正确显示方程 (1)、(2)、(3),引用 (1)/(3) 和文献 [1]/[2]。用户当前项目刷新前后 4 个文件逐字节相同。
+
+高级输入与图形输入切换须保留新标签、编号和注释;未修改即确认须保留原始公式,不以数学组件的归一化结果覆盖原文。两项都有事件回归。内嵌浏览器仅允许 HTTP/HTTPS,本次未实跑 file:// UI;本地资源、字体白名单及打包完整性有自动检查。
+
+前端改版验收:在 1280×720 和 1383×1084 的真实浏览器检查工具栏、文稿、引用及公式弹窗、双栏 PDF;确认公式按钮固定可见,原有引用点击、源码/可视化切换、公式保存/撤销和 PDF 下载均工作。多行公式 Ctrl/Cmd+Enter 须先阻止数学组件的插行快捷键再确认,输出不能多出占位行;普通 Enter 仍可编辑矩阵行。已有事件回归覆盖这一区别。用户当前项目 4 文件前后逐字节不变。
diff --git a/tests/compiler.test.mjs b/tests/compiler.test.mjs
new file mode 100644
index 0000000..cf6efe6
--- /dev/null
+++ b/tests/compiler.test.mjs
@@ -0,0 +1,257 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {access, chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile} from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import {compileProject, discoverEngines, runProcess} from '../compiler.mjs';
+
+const pdf = Buffer.from('%PDF-1.7\nfixture\n%%EOF');
+const text = (path, text, encoding = 'utf-8') => ({path, kind: 'text', text, encoding});
+const project = (files, mainPath = files[0]?.path) => ({format: 'visual-workspace', version: 1, name: 'Test project', files, mainPath, activePath: mainPath});
+const engines = {
+ latex: {available: true, engine: 'tectonic', label: 'Tectonic', executable: '/configured/tectonic'},
+ postscript: {available: true, engine: 'ghostscript', label: 'Ghostscript', executable: '/configured/ghostscript'}
+};
+async function root(t) {
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'visual-compiler-tests-'));
+ t.after(() => rm(directory, {recursive: true, force: true}));
+ return directory;
+}
+function emitPdf(args, options) {
+ const gs = args.find(arg => arg.startsWith('-sOutputFile='));
+ const target = gs ? gs.slice('-sOutputFile='.length) : args.some(arg => arg.startsWith('-jobname='))
+ ? path.join(options.outputDirectory, 'visual-workspace.pdf')
+ : path.join(options.outputDirectory, path.parse(args.at(-1)).name + '.pdf');
+ return writeFile(target, pdf);
+}
+
+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' : ''));
+ await writeFile(binary, 'fixture');
+ await chmod(binary, 0o755);
+ const found = await discoverEngines({toolsDirectory: directory, env: {PATH: '', VISUALLATEX_TECTONIC: binary, VISUALLATEX_GHOSTSCRIPT: binary}});
+ assert.equal(found.latex.engine, 'tectonic');
+ assert.equal(found.latex.executable, binary);
+ assert.equal(found.postscript.available, true);
+ const missing = await discoverEngines({toolsDirectory: directory, env: {PATH: '', VISUALLATEX_TECTONIC: path.join(directory, 'missing')}});
+ assert.equal(missing.latex.available, false);
+ assert.equal(missing.postscript.available, false);
+});
+
+test('portable engines are found automatically while an explicit XeLaTeX choice wins', async t => {
+ const directory = await root(t);
+ const extension = process.platform === 'win32' ? '.exe' : '';
+ const tectonic = path.join(directory, 'tectonic', 'tectonic' + extension);
+ const gs = path.join(directory, 'ghostscript', 'bin', process.platform === 'win32' ? 'gswin64c.exe' : 'gs');
+ const xelatex = path.join(directory, 'xelatex' + extension);
+ for (const binary of [tectonic, gs, xelatex]) {
+ await mkdir(path.dirname(binary), {recursive: true});
+ await writeFile(binary, 'fixture'); await chmod(binary, 0o755);
+ }
+ const portable = await discoverEngines({toolsDirectory: directory, env: {PATH: ''}});
+ assert.equal(portable.latex.executable, tectonic);
+ assert.equal(portable.postscript.executable, gs);
+ const selected = await discoverEngines({toolsDirectory: directory, env: {PATH: '', VISUALLATEX_XELATEX: xelatex}});
+ assert.equal(selected.latex.engine, 'xelatex');
+ assert.equal(selected.latex.executable, xelatex);
+});
+
+test('LaTeX compilation stages all files unchanged, passes untrusted flags and removes its temporary tree', async t => {
+ const temporaryParent = await root(t);
+ const binary = Buffer.from([0, 255, 128, 1, 2]);
+ const files = [text('docs/main.tex', '\\input{chapter}\r\n'), text('docs/chapter.tex', 'A citation \\cite{key}.'),
+ text('refs.bib', '@book{key,title={Book}}'), text('figures/latin.ps', '%!PS\né', 'latin1'),
+ {path: 'figures/picture.bin', kind: 'binary', base64: binary.toString('base64')}];
+ let jobDirectory;
+ const result = await compileProject(project(files), {kind: 'latex', engines, temporaryParent,
+ runner: async (command, args, options) => {
+ assert.equal(command, engines.latex.executable);
+ assert.ok(args.includes('--untrusted'));
+ assert.equal(options.env.TECTONIC_UNTRUSTED_MODE, '1');
+ assert.equal(options.env.shell_escape, '0');
+ assert.equal(options.cwd, path.join(options.sourceDirectory, 'docs'));
+ assert.equal(await readFile(path.join(options.sourceDirectory, 'docs/main.tex'), 'utf8'), files[0].text);
+ assert.equal(await readFile(path.join(options.sourceDirectory, 'refs.bib'), 'utf8'), files[2].text);
+ assert.deepEqual(await readFile(path.join(options.sourceDirectory, 'figures/picture.bin')), binary);
+ assert.deepEqual(await readFile(path.join(options.sourceDirectory, 'figures/latin.ps')), Buffer.from(files[3].text, 'latin1'));
+ jobDirectory = path.dirname(options.sourceDirectory);
+ await emitPdf(args, options);
+ await writeFile(path.join(options.outputDirectory, 'main.log'), 'A useful warning');
+ return {code: 0, log: 'Compiled'};
+ }});
+ assert.deepEqual(result.pdf, pdf);
+ assert.equal(result.mainPath, 'docs/main.tex');
+ assert.match(result.log, /useful warning/);
+ await assert.rejects(access(jobDirectory));
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('PS and binary EPS render through Ghostscript pdfwrite with SAFER and appropriate cropping', async t => {
+ const temporaryParent = await root(t);
+ for (const extension of ['ps', 'eps']) {
+ const contents = Buffer.from(extension === 'eps' ? [0xc5, 0xd0, 0xd3, 0xc6, 0, 255] : '%!PS\nshowpage');
+ const source = {path: 'drawing.' + extension, kind: 'binary', base64: contents.toString('base64')};
+ 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);
+ 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=')));
+ assert.ok(args.some(arg => arg.startsWith('--permit-file-write=')));
+ assert.equal(options.env.GS_OPTIONS, undefined);
+ assert.deepEqual(await readFile(args.at(-1)), contents);
+ await emitPdf(args, options);
+ return {code: 0, log: 'Rendered'};
+ }});
+ assert.deepEqual(result.pdf, pdf);
+ }
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('PostScript permits staged resources by relative filename without granting arbitrary reads', async t => {
+ 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) => {
+ 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=*'));
+ assert.ok(!args.includes('-dNOSAFER'));
+ await emitPdf(args, options); return {code: 0};
+ }});
+ assert.deepEqual(await readdir(temporaryParent), []);
+ let invoked = false;
+ await assert.rejects(compileProject(project([text('drawing.ps', 'showpage'), text('name' + path.delimiter + '/outside', 'resource')]), {
+ kind: 'postscript', engines, temporaryParent, runner: async () => { invoked = true; }
+ }));
+ assert.equal(invoked, false, 'a filename cannot add an extra Ghostscript permission');
+});
+
+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')]), {
+ kind: 'latex', engines, temporaryParent, runner: async (_, args, options) => {
+ assert.ok(args.includes('--keep-intermediates'));
+ await emitPdf(args, options);
+ await writeFile(path.join(options.outputDirectory, 'main.bcf'), '
');
+ return {code: 0};
+ }}), {code: 'BIBLIOGRAPHY_ENGINE_MISSING'});
+ assert.deepEqual(await readdir(temporaryParent), []);
+ await assert.rejects(compileProject(project([text('main.tex', 'biblatex with Biber')]), {
+ kind: 'latex', engines, temporaryParent,
+ runner: async () => ({code: 1, log:'note: Running external tool biber ...\nerror: program not found'})
+ }), {code: 'BIBLIOGRAPHY_ENGINE_MISSING'});
+});
+
+test('filesystem-unsafe paths, directory collisions and wrong main kinds are rejected before running an engine', async t => {
+ const temporaryParent = await root(t);
+ let calls = 0;
+ const options = {kind: 'latex', engines, temporaryParent, runner: async () => { calls++; }};
+ 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('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);
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('missing engine and pre-cancelled request leave no temporary files', async t => {
+ const temporaryParent = await root(t);
+ const source = project([text('main.tex', 'x')]);
+ await assert.rejects(compileProject(source, {kind: 'latex', temporaryParent, engines: {latex: {available: false}}}), {code: 'ENGINE_MISSING'});
+ const controller = new AbortController(); controller.abort();
+ await assert.rejects(compileProject(source, {kind: 'latex', temporaryParent, engines, signal: controller.signal}), {code: 'CANCELLED'});
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('compiler failures retain bounded logs and clean every staged file', async t => {
+ const temporaryParent = await root(t);
+ await assert.rejects(compileProject(project([text('main.tex', 'bad')]), {kind: 'latex', engines, temporaryParent,
+ runner: async (_, __, options) => {
+ await writeFile(path.join(options.outputDirectory, 'main.log'), 'x'.repeat(700000) + '\nmain.tex:4: Undefined command');
+ return {code: 1, log: 'TeX error'};
+ }}), error => {
+ assert.equal(error.code, 'COMPILE_FAILED');
+ assert.match(error.log, /main.tex:4: Undefined command/);
+ assert.ok(Buffer.byteLength(error.log) < 530000);
+ return true;
+ });
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('missing or non-PDF engine output is never returned as a successful preview', async t => {
+ const temporaryParent = await root(t);
+ for (const invalid of [null, 'not a PDF']) {
+ await assert.rejects(compileProject(project([text('main.tex', 'x')]), {kind: 'latex', engines, temporaryParent,
+ runner: async (_, args, options) => {
+ if (invalid) await writeFile(path.join(options.outputDirectory, 'main.pdf'), invalid);
+ return {code: 0, log: ''};
+ }}), {code: invalid ? 'INVALID_OUTPUT' : 'PDF_MISSING'});
+ }
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('timeout and cancellation stop the runner and remove staged project data', async t => {
+ const temporaryParent = await root(t);
+ for (const action of ['timeout', 'cancel']) {
+ const controller = new AbortController();
+ const pending = compileProject(project([text('main.tex', 'x')]), {kind: 'latex', engines, temporaryParent,
+ timeoutMs: action === 'timeout' ? 20 : 2000, signal: controller.signal,
+ runner: (_, __, {signal}) => new Promise((resolve, reject) => {
+ signal.addEventListener('abort', () => reject(signal.reason), {once: true});
+ if (action === 'cancel') controller.abort();
+ })});
+ await assert.rejects(pending, {code: action === 'timeout' ? 'COMPILE_TIMEOUT' : 'CANCELLED'});
+ assert.deepEqual(await readdir(temporaryParent), []);
+ }
+});
+
+test('XeLaTeX runs bibliography and subsequent reference passes with shell escape disabled', async t => {
+ const temporaryParent = await root(t);
+ const calls = [];
+ const configured = {latex: {available: true, engine: 'xelatex', executable: '/engine/xelatex', bibtex: '/engine/bibtex'}};
+ const result = await compileProject(project([text('main.tex', 'source'), text('refs.bib', '@book{key,title={Book}}')]), {
+ kind: 'latex', engines: configured, temporaryParent, runner: async (command, args, options) => {
+ calls.push(command);
+ if (command.endsWith('xelatex')) {
+ assert.ok(args.includes('-no-shell-escape'));
+ assert.ok(args.includes('-no-parse-first-line'));
+ await writeFile(path.join(options.outputDirectory, 'visual-workspace.aux'), '\\bibdata{refs}\n');
+ await emitPdf(args, options);
+ } else {
+ assert.deepEqual(args, ['visual-workspace']);
+ assert.match(options.env.BIBINPUTS, /project/);
+ }
+ return {code: 0, log: 'ok'};
+ }});
+ assert.deepEqual(calls, ['/engine/xelatex', '/engine/bibtex', '/engine/xelatex', '/engine/xelatex']);
+ assert.deepEqual(result.pdf, pdf);
+});
+
+test('XeLaTeX reports missing bibliography engines instead of claiming successful references', async t => {
+ const temporaryParent = await root(t);
+ await assert.rejects(compileProject(project([text('main.tex', 'source')]), {
+ kind: 'latex', engines: {latex: {available: true, engine: 'xelatex', executable: '/engine/xelatex'}}, temporaryParent,
+ runner: async (_, __, options) => {
+ await writeFile(path.join(options.outputDirectory, 'visual-workspace.aux'), '\\bibdata{refs}\n');
+ return {code: 0};
+ }}), {code: 'BIBLIOGRAPHY_ENGINE_MISSING'});
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('actual process runner treats arguments literally and captures nonzero exits', async () => {
+ const literal = 'spaces & | $(not-a-shell) %test%';
+ const result = await runProcess(process.execPath, ['-e', 'process.stdout.write(process.argv[1]); process.exitCode=7', literal]);
+ assert.equal(result.code, 7);
+ assert.equal(result.log, literal);
+});
+
+test('actual process runner terminates a running process on abort', async () => {
+ const controller = new AbortController();
+ const pending = runProcess(process.execPath, ['-e', 'setInterval(()=>{},1000)'], {signal: controller.signal});
+ const timer = setTimeout(() => controller.abort(), 100);
+ try { await assert.rejects(pending, /取消|abort/i); }
+ finally { clearTimeout(timer); }
+});
diff --git a/tests/editor-regression.test.mjs b/tests/editor-regression.test.mjs
index 7238ed5..47099bb 100644
--- a/tests/editor-regression.test.mjs
+++ b/tests/editor-regression.test.mjs
@@ -45,7 +45,7 @@ function fixture() {
return null;
}
addEventListener(type, handler) { this.listeners[type] = handler; }
- focus() {}
+ focus() { document.activeElement = this; }
}
function textNode(value) {
return { nodeType: 3, textContent: String(value), get parentElement() { return this.parentNode; } };
@@ -81,13 +81,15 @@ function fixture() {
const prompts = [];
const calls = { prompts: 0, saves: 0, previews: 0, statuses: [] };
const document = {
- addEventListener() {},
+ listeners: {},
+ addEventListener(type, handler) { this.listeners[type] = handler; },
getElementById: id => ids[id],
createElement: tag => new Element(tag),
createTextNode: textNode,
createRange: () => new Range(ids.editorArea),
querySelector: selector => selector.includes(':checked') ? radios.find(r => r.checked)
: radios.find(r => selector.includes(`value="${r.value}"`)),
+ querySelectorAll: selector => selector === 'input[name="formulaMode"]' ? radios : [],
};
const context = vm.createContext({ document, Node: { ELEMENT_NODE: 1, TEXT_NODE: 3 },
window: { getSelection: () => selection }, console,
@@ -104,7 +106,17 @@ function fixture() {
ids.editorArea.appendChild(node);
return node;
}
- return { context, ids, selection, prompts, calls, Element, Range, formula, radios };
+ return { context, ids, selection, prompts, calls, Element, Range, formula, radios, document };
+}
+
+function modalKey(f, key, properties = {}) {
+ const event = {type: 'keydown', key, target: f.ids.formulaInput, defaultPrevented: false, propagationStopped: false,
+ preventDefault() { this.defaultPrevented = true; }, stopPropagation() { this.propagationStopped = true; }, ...properties};
+ // Match addEventListener's currentTarget/this binding and event bubbling.
+ // Parent listeners are deliberately installed by each shortcut regression.
+ f.ids.formulaModal.listeners.keydown.call(f.ids.formulaModal, event);
+ if (!event.propagationStopped) f.document.listeners.keydown?.call(f.document, event);
+ return event;
}
test('image insertion without an existing selection appends to the editor', () => {
@@ -129,6 +141,26 @@ test('an external selection is never deleted or used for insertion', () => {
assert.equal(f.ids.editorArea.childNodes[0].tagName, 'IMG');
});
+test('image parameters from an asynchronous dialog restore the saved editor selection without native prompts', () => {
+ const f = fixture();
+ const original = new f.Element('p');
+ original.textContent = 'Original text';
+ f.ids.editorArea.appendChild(original);
+ f.selection.addRange(new f.Range(f.ids.editorArea, 0));
+ f.context.saveEditorSelection();
+ f.selection.removeAllRanges();
+ const outside = new f.Element('input');
+ const externalRange = new f.Range(outside);
+ f.selection.addRange(externalRange);
+ f.context.insertImage({imagePath: 'figure.png', caption: '
Caption ', label: 'fig:async'});
+ assert.equal(f.calls.prompts, 0);
+ assert.equal(f.calls.saves, 1);
+ assert.equal(externalRange.deleted, false);
+ assert.equal(f.ids.editorArea.childNodes[0].tagName, 'IMG');
+ assert.equal(f.ids.editorArea.childNodes[0].getAttribute('data-caption'), '
Caption ');
+ assert.ok(f.ids.editorArea.childNodes.includes(original));
+});
+
test('cancelling the image path immediately stops the interaction', () => {
const f = fixture();
f.prompts.push(null);
@@ -163,6 +195,232 @@ test('double-clicking an existing formula loads its value and mode', () => {
assert.equal(f.ids.btnInsertFormula.textContent, '更新公式');
});
+test('a single click opens the formula editor from rendered children or their text', () => {
+ for (const textTarget of [false, true]) {
+ const f = fixture();
+ const formula = f.formula(String.raw`\frac{x}{y}`, 'block');
+ const rendered = new f.Element('span');
+ rendered.textContent = 'rendered fraction';
+ formula.appendChild(rendered);
+ f.context.initFormulaModal();
+ let prevented = false;
+ f.ids.editorArea.listeners.click({type: 'click', target: textTarget ? rendered.childNodes[0] : rendered,
+ preventDefault() { prevented = true; }});
+ assert.equal(prevented, true);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), true);
+ assert.equal(f.ids.formulaInput.value, String.raw`\frac{x}{y}`);
+ assert.equal(f.radios.find(r => r.checked).value, 'block');
+ assert.equal(f.ids.btnInsertFormula.textContent, '更新公式');
+ assert.equal(f.calls.saves, 0);
+ }
+});
+
+test('Enter and Space open a focused formula while ordinary typing and outside formulas do not', () => {
+ for (const key of ['Enter', ' ', 'ArrowRight', 'a']) {
+ const f = fixture();
+ const formula = f.formula('x + 1');
+ f.context.initFormulaModal();
+ let prevented = false;
+ f.ids.editorArea.listeners.keydown({type: 'keydown', key, target: formula,
+ preventDefault() { prevented = true; }});
+ const activated = key === 'Enter' || key === ' ';
+ assert.equal(prevented, activated);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), activated);
+ assert.equal(f.calls.saves, 0);
+ }
+ const f = fixture();
+ f.context.initFormulaModal();
+ const outside = new f.Element('span'); outside.className = 'formula-inline';
+ let prevented = false;
+ f.ids.editorArea.listeners.click({type: 'click', target: outside, preventDefault() { prevented = true; }});
+ assert.equal(prevented, false);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), false);
+});
+
+for (const environment of ['equation', 'equation*', 'align', 'align*', 'gather', 'gather*']) {
+ test('editing ' + environment + ' through formula events preserves exact environment boundaries', () => {
+ const f = fixture();
+ const original = '\r\nx &= 1\\\\\r\ny &= 2\\label{eq:result}\r\n';
+ const updated = original.replace('x &= 1', 'x &= 3');
+ const opening = '\\begin \t{' + environment + '}';
+ const closing = '\\end\r\n{' + environment + '}';
+ const formula = f.formula(original, 'block');
+ formula.setAttribute('data-delimiter', 'environment');
+ formula.setAttribute('data-environment', environment);
+ formula.setAttribute('data-tex-env-open', opening);
+ formula.setAttribute('data-tex-env-close', closing);
+ formula.setAttribute('data-tex-source', opening + original + closing);
+ formula.setAttribute('data-tex-snapshot', 'old snapshot');
+ f.context.initFormulaModal();
+ f.ids.editorArea.listeners.click({type: 'click', target: formula, preventDefault() {}});
+ assert.equal(f.radios.find(r => r.checked).value, 'block');
+ assert.ok(f.radios.every(r => r.disabled));
+ f.ids.formulaInput.value = updated;
+ f.ids.btnInsertFormula.listeners.click({type: 'click'});
+ assert.equal(f.ids.editorArea.childNodes.length, 1);
+ const replacement = f.ids.editorArea.childNodes[0];
+ assert.notEqual(replacement, formula);
+ assert.equal(replacement.getAttribute('data-formula'), updated);
+ assert.equal(replacement.getAttribute('data-delimiter'), 'environment');
+ assert.equal(replacement.getAttribute('data-environment'), environment);
+ assert.equal(replacement.getAttribute('data-tex-env-open'), opening);
+ assert.equal(replacement.getAttribute('data-tex-env-close'), closing);
+ assert.equal(replacement.getAttribute('data-tex-source'), null);
+ assert.equal(replacement.getAttribute('data-tex-snapshot'), null);
+ assert.equal(f.context.convertNodeToLatex(replacement), opening + updated + closing);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), false);
+ assert.equal(f.calls.saves, 1);
+ assert.equal(f.calls.previews, 1);
+ f.context.openFormulaModal('inline');
+ assert.ok(f.radios.every(r => !r.disabled));
+ });
+}
+
+test('cancelling environment edits with close or Escape preserves the original atom and source', () => {
+ for (const action of ['close', 'escape']) {
+ const f = fixture();
+ const formula = f.formula('\nx = 1\\label{eq:original}\n', 'block');
+ const source = '\\begin{equation}' + formula.getAttribute('data-formula') + '\\end{equation}';
+ formula.setAttribute('data-delimiter', 'environment');
+ formula.setAttribute('data-environment', 'equation');
+ formula.setAttribute('data-tex-env-open', '\\begin{equation}');
+ formula.setAttribute('data-tex-env-close', '\\end{equation}');
+ formula.setAttribute('data-tex-source', source);
+ f.context.initFormulaModal();
+ f.ids.editorArea.listeners.keydown({type: 'keydown', key: 'Enter', target: formula, preventDefault() {}});
+ f.ids.formulaInput.value = 'discarded';
+ if (action === 'close') f.ids.formulaModalClose.listeners.click({type: 'click'});
+ else f.document.listeners.keydown({type: 'keydown', key: 'Escape'});
+ assert.equal(f.ids.editorArea.childNodes[0], formula);
+ assert.equal(formula.getAttribute('data-tex-source'), source);
+ assert.equal(formula.getAttribute('data-environment'), 'equation');
+ assert.equal(f.ids.formulaModal.classList.contains('show'), false);
+ assert.equal(f.calls.saves, 0);
+ }
+});
+
+test('formula confirmation shortcuts edit once without reaching workspace compile handlers', () => {
+ for (const modifier of ['ctrlKey', 'metaKey']) {
+ const f = fixture();
+ f.context.initFormulaModal();
+ f.context.openFormulaModal('inline');
+ f.ids.formulaInput.value = 'x + 1';
+ const event = {key: 'Enter', [modifier]: true, defaultPrevented: false, propagationStopped: false,
+ preventDefault() { this.defaultPrevented = true; },
+ stopPropagation() { this.propagationStopped = true; }};
+ f.ids.formulaInput.listeners.keydown(event);
+ let compileCalls = 0;
+ if (!event.propagationStopped) compileCalls++;
+ assert.equal(event.defaultPrevented, true);
+ assert.equal(compileCalls, 0);
+ assert.equal(f.ids.editorArea.childNodes.filter(node => node.getAttribute?.('data-formula') === 'x + 1').length, 1);
+ assert.equal(f.calls.saves, 1);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), false);
+ }
+});
+
+test('modal Ctrl+S and Cmd+S commit the formula exactly once before document persistence', () => {
+ for (const modifier of ['ctrlKey', 'metaKey']) {
+ const f = fixture();
+ const original = f.formula('x');
+ f.context.initFormulaModal();
+ f.context.openFormulaModal('inline', original);
+ f.ids.formulaInput.value = 'x + 1';
+ let documentSaves = 0;
+ f.document.listeners.keydown = event => {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') documentSaves++;
+ };
+ const event = modalKey(f, modifier === 'ctrlKey' ? 's' : 'S', {[modifier]: true});
+ assert.equal(event.defaultPrevented, true);
+ assert.equal(event.propagationStopped, true);
+ assert.equal(documentSaves, 0);
+ assert.equal(f.calls.saves, 1);
+ assert.equal(f.calls.previews, 1);
+ assert.equal(f.ids.editorArea.childNodes.length, 1);
+ assert.equal(f.ids.editorArea.childNodes[0].getAttribute('data-formula'), 'x + 1');
+ assert.equal(f.ids.formulaModal.classList.contains('show'), false);
+ }
+});
+
+test('modal undo shortcuts stay inside the formula field without preventing its native undo', () => {
+ for (const modifier of ['ctrlKey', 'metaKey']) {
+ const f = fixture();
+ const original = f.formula('x');
+ f.context.initFormulaModal();
+ f.context.openFormulaModal('inline', original);
+ f.ids.formulaInput.value = 'draft';
+ let documentUndos = 0;
+ f.document.listeners.keydown = () => documentUndos++;
+ const event = modalKey(f, 'z', {[modifier]: true});
+ assert.equal(event.propagationStopped, true);
+ assert.equal(event.defaultPrevented, false);
+ assert.equal(documentUndos, 0);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), true);
+ assert.equal(f.ids.editorArea.childNodes[0], original);
+ assert.equal(f.calls.saves, 0);
+ }
+});
+
+test('modal Escape cancels the draft without propagating to document handlers', () => {
+ const f = fixture();
+ const original = f.formula('x');
+ f.context.initFormulaModal();
+ f.context.openFormulaModal('inline', original);
+ f.ids.formulaInput.value = 'discarded';
+ let documentKeys = 0;
+ f.document.listeners.keydown = () => documentKeys++;
+ const event = modalKey(f, 'Escape');
+ assert.equal(event.defaultPrevented, true);
+ assert.equal(event.propagationStopped, true);
+ assert.equal(documentKeys, 0);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), false);
+ assert.equal(f.ids.editorArea.childNodes[0], original);
+ assert.equal(original.getAttribute('data-formula'), 'x');
+ assert.equal(f.calls.saves, 0);
+ assert.equal(f.document.activeElement, f.ids.editorArea);
+});
+
+test('modal Tab loops only at visible enabled control boundaries', () => {
+ const f = fixture();
+ f.context.initFormulaModal();
+ f.context.openFormulaModal('inline');
+ const disabled = new f.Element('button'); disabled.disabled = true;
+ const hidden = new f.Element('textarea');
+ const fields = [disabled, f.ids.formulaModalClose, f.ids.formulaInput, f.ids.btnInsertFormula, hidden];
+ for (const field of fields) field.getClientRects = () => [{}];
+ hidden.getClientRects = () => [];
+ f.ids.formulaModal.querySelectorAll = selector => {
+ assert.equal(selector, 'button,input,textarea,summary,math-field');
+ return fields;
+ };
+ let documentKeys = 0;
+ f.document.listeners.keydown = () => documentKeys++;
+ f.ids.btnInsertFormula.focus();
+ assert.equal(modalKey(f, 'Tab').defaultPrevented, true);
+ assert.equal(f.document.activeElement, f.ids.formulaModalClose);
+ assert.equal(modalKey(f, 'Tab', {shiftKey: true}).defaultPrevented, true);
+ assert.equal(f.document.activeElement, f.ids.btnInsertFormula);
+ f.ids.formulaInput.focus();
+ assert.equal(modalKey(f, 'Tab').defaultPrevented, false);
+ assert.equal(f.document.activeElement, f.ids.formulaInput);
+ assert.equal(documentKeys, 0);
+ assert.equal(f.calls.saves, 0);
+});
+
+test('an empty formula confirmation remains in the modal without starting compilation', () => {
+ const f = fixture();
+ f.context.initFormulaModal();
+ f.context.openFormulaModal('inline');
+ let propagationStopped = false;
+ f.ids.formulaInput.listeners.keydown({key: 'Enter', ctrlKey: true,
+ preventDefault() {}, stopPropagation() { propagationStopped = true; }});
+ assert.equal(propagationStopped, true);
+ assert.equal(f.ids.formulaModal.classList.contains('show'), true);
+ assert.equal(f.ids.editorArea.childNodes.length, 0);
+ assert.equal(f.calls.saves, 0);
+ assert.equal(f.calls.statuses.at(-1), '请输入公式内容');
+});
+
test('editing an existing formula replaces it once and invalidates old source metadata', () => {
const f = fixture();
const formula = f.formula('x');
diff --git a/tests/helpers/workspace-fixture.mjs b/tests/helpers/workspace-fixture.mjs
new file mode 100644
index 0000000..4e869a0
--- /dev/null
+++ b/tests/helpers/workspace-fixture.mjs
@@ -0,0 +1,286 @@
+import fs from 'node:fs';
+import vm from 'node:vm';
+import {createDocument, Element, Text} from './dom.mjs';
+
+// Actual markup, production startup and event handlers; the local fixture adds
+// basic form/events to the shared deterministic DOM. No browser engine, layout,
+// automatic HTML repair, scripts in user text or OS clipboard are simulated.
+const markup = fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
+const sources = ['main.js', 'project-model.js', 'project-archive.js', 'workspace.js']
+ .map(name => [name, fs.readFileSync(new URL('../../' + name, import.meta.url), 'utf8')]);
+
+export async function fixture({denyStorage = false, denyDialogs = false, initialStorage = [], includeVisualReferences = false, includeVisualMath = false} = {}) {
+ const root = new Element('fixture-root');
+ root.innerHTML = markup;
+ const storage = new Map(initialStorage);
+ const prompts = [];
+ const alerts = [];
+ const blobs = [];
+ const tasks = new Map();
+ let timer = 0;
+ let document;
+ const addEvents = target => {
+ target._events = new Map();
+ target._captureEvents = new Map();
+ const eventMap = options => options === true || options?.capture ? target._captureEvents : target._events;
+ target.addEventListener = (type, listener, options = false) => {
+ const map = eventMap(options);
+ if (!map.has(type)) map.set(type, []);
+ map.get(type).push(listener);
+ };
+ target.removeEventListener = (type, listener, options = false) => {
+ const map = eventMap(options);
+ map.set(type, (map.get(type) || []).filter(item => item !== listener));
+ };
+ };
+ function eventFor(target, type, properties) {
+ return {type, target, defaultPrevented: false, propagationStopped: false, immediatePropagationStopped: false,
+ preventDefault() { this.defaultPrevented = true; },
+ stopPropagation() { this.propagationStopped = true; },
+ stopImmediatePropagation() { this.immediatePropagationStopped = this.propagationStopped = true; }, ...properties};
+ }
+ async function invoke(target, type, event, capture) {
+ event.currentTarget = target;
+ const map = capture ? target._captureEvents : target._events;
+ for (const listener of map?.get(type) || []) {
+ await listener.call(target, event);
+ if (event.immediatePropagationStopped) break;
+ }
+ }
+ async function emit(target, type, properties = {}) {
+ const event = eventFor(target, type, properties);
+ event.currentTarget = target;
+ // Keep target-only delivery timing unchanged for callers that start an
+ // async prompt and inspect it before answering it.
+ for (const listener of target._captureEvents?.get(type) || []) {
+ await listener.call(target, event);
+ if (event.immediatePropagationStopped) return event;
+ }
+ for (const listener of target._events?.get(type) || []) {
+ await listener.call(target, event);
+ if (event.immediatePropagationStopped) break;
+ }
+ return event;
+ }
+ // Opt-in propagation for custom-element integration regressions. Existing
+ // emit() calls remain target-only so their fixtures do not simulate layout.
+ async function dispatch(target, type, properties = {}) {
+ const event = eventFor(target, type, properties);
+ const path = [];
+ for (let node = target; node; node = node.parentNode) path.push(node);
+ if (!path.includes(document)) path.push(document);
+ event.composedPath = () => path.slice();
+ for (const ancestor of path.slice(1).reverse()) {
+ event.eventPhase = 1;
+ await invoke(ancestor, type, event, true);
+ if (event.propagationStopped) return event;
+ }
+ event.eventPhase = 2;
+ await invoke(target, type, event, true);
+ if (!event.immediatePropagationStopped) await invoke(target, type, event, false);
+ if (event.propagationStopped) return event;
+ for (const ancestor of path.slice(1)) {
+ event.eventPhase = 3;
+ await invoke(ancestor, type, event, false);
+ if (event.propagationStopped) return event;
+ }
+ return event;
+ }
+ function enhance(node) {
+ if (node.nodeType !== 1 || node._events) return node;
+ addEvents(node);
+ node.classList = {
+ contains: name => node.className.split(/\s+/).includes(name),
+ add(...names) { node.className = [...new Set([...node.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
+ remove(...names) { node.className = node.className.split(/\s+/).filter(name => !names.includes(name)).join(' '); },
+ toggle(name, force) {
+ const add = force ?? !this.contains(name);
+ if (add) this.add(name); else this.remove(name);
+ return add;
+ },
+ };
+ node.replaceChildren = (...children) => {
+ for (const child of [...node.childNodes]) child.remove();
+ for (const child of children) node.appendChild(typeof child === 'string' ? new Text(child) : child);
+ };
+ node.replaceChild = (next, old) => { old.replaceWith(next); return old; };
+ const queryAll = node.querySelectorAll.bind(node);
+ node.querySelectorAll = selector => selector.includes(',')
+ ? queryAll('*').filter(child => selector.split(',').some(part => child.matches(part.trim()))) : queryAll(selector);
+ node.querySelector = selector => node.querySelectorAll(selector)[0] || null;
+ node.closest = selector => {
+ for (let current = node; current; current = current.parentElement) {
+ if (selector.split(',').some(part => current.matches(part.trim()))) return current;
+ }
+ return null;
+ };
+ node.focus = () => { document.activeElement = node; };
+ node.click = () => node.disabled ? Promise.resolve() : emit(node, 'click');
+ node.scrollTop = 0;
+ node.scrollLeft = 0;
+ node.selectionStart = node.selectionEnd = 0;
+ let value = node.getAttribute('value') || (node.tagName === 'TEXTAREA' ? node.textContent : '');
+ Object.defineProperty(node, 'value', {
+ configurable: true,
+ get: () => value,
+ set(next) {
+ const normalized = String(next).replace(/\r\n?/g, '\n');
+ if (normalized !== value) node.selectionStart = node.selectionEnd = normalized.length;
+ value = normalized;
+ },
+ });
+ Object.defineProperty(node, 'hidden', {
+ get: () => node.getAttribute('hidden') !== null,
+ set(hidden) { if (hidden) node.setAttribute('hidden', ''); else node.removeAttribute('hidden'); },
+ });
+ node.disabled = node.getAttribute('disabled') !== null;
+ let checked = node.getAttribute('checked') !== null;
+ Object.defineProperty(node, 'checked', {
+ get: () => checked,
+ set(value) {
+ if (value && node.getAttribute('type') === 'radio') {
+ for (const other of root.querySelectorAll('input')) {
+ if (other !== node && other.getAttribute('type') === 'radio' && other.getAttribute('name') === node.getAttribute('name')) other.checked = false;
+ }
+ }
+ checked = Boolean(value);
+ },
+ });
+ if (node.tagName === 'INPUT' && node.getAttribute('type') === 'file') node.files = [];
+ node.setSelectionRange = (start, end) => {
+ node.selectionStart = Math.max(0, Math.min(value.length, start));
+ node.selectionEnd = Math.max(node.selectionStart, Math.min(value.length, end));
+ };
+ node.select = () => node.setSelectionRange(0, value.length);
+ return node;
+ }
+ for (const node of root.querySelectorAll('*')) enhance(node);
+ const ids = new Map(root.querySelectorAll('[id]').map(node => [node.getAttribute('id'), node]));
+ const base = createDocument();
+ document = {...base, body: root.querySelector('body'), activeElement: null, visibilityState: 'visible',
+ getElementById: id => ids.get(id) || null,
+ createElement: tag => enhance(base.createElement(tag)),
+ createTextNode: value => new Text(String(value)),
+ querySelector(selector) {
+ if (selector.startsWith('input[name="formulaMode"]')) {
+ const radios = root.querySelectorAll('input').filter(node => node.getAttribute('name') === 'formulaMode');
+ if (selector.endsWith(':checked')) return radios.find(node => node.checked) || null;
+ const requested = /\[value="([^"]+)"\]/.exec(selector)?.[1];
+ return radios.find(node => node.value === requested) || null;
+ }
+ return root.querySelector(selector);
+ },
+ querySelectorAll(selector) {
+ if (selector === 'input[name="formulaMode"]') return root.querySelectorAll('input').filter(node => node.getAttribute('name') === 'formulaMode');
+ return root.querySelectorAll(selector);
+ },
+ execCommand() { return true; },
+ };
+ addEvents(document);
+ // A minimal live Range for checking cursor restoration, not layout or a
+ // replacement for browser acceptance. Mutations are real fixture DOM edits.
+ class Range {
+ constructor() { this.startContainer = document.body; this.endContainer = document.body; this.startOffset = this.endOffset = 0; }
+ get commonAncestorContainer() {
+ for (let node = this.startContainer; node; node = node.parentNode) {
+ if (node === this.endContainer || node.contains?.(this.endContainer)) return node;
+ }
+ return document.body;
+ }
+ setStart(node, offset) { this.startContainer = node; this.startOffset = offset; }
+ setEnd(node, offset) { this.endContainer = node; this.endOffset = offset; }
+ setStartAfter(node) { this.setStart(node.parentNode, node.parentNode.childNodes.indexOf(node) + 1); }
+ setEndAfter(node) { this.setEnd(node.parentNode, node.parentNode.childNodes.indexOf(node) + 1); }
+ selectNodeContents(node) { this.setStart(node, 0); this.setEnd(node, node.nodeType === 3 ? node.textContent.length : node.childNodes.length); }
+ collapse(toStart = false) {
+ if (toStart) this.setEnd(this.startContainer, this.startOffset);
+ else this.setStart(this.endContainer, this.endOffset);
+ }
+ cloneRange() { const clone = new Range(); Object.assign(clone, this); return clone; }
+ deleteContents() {
+ if (this.startContainer !== this.endContainer) throw new Error('The fixture supports selections within a single node');
+ if (this.startContainer.nodeType === 3) {
+ const node = this.startContainer;
+ node.textContent = node.textContent.slice(0, this.startOffset) + node.textContent.slice(this.endOffset);
+ } else for (const child of this.startContainer.childNodes.slice(this.startOffset, this.endOffset)) child.remove();
+ this.collapse(true);
+ }
+ insertNode(node) {
+ let parent = this.startContainer;
+ let position = this.startOffset;
+ if (parent.nodeType === 3) {
+ const original = parent;
+ const after = new Text(original.textContent.slice(position));
+ original.textContent = original.textContent.slice(0, position);
+ parent = original.parentNode;
+ position = parent.childNodes.indexOf(original) + 1;
+ after.parentNode = parent; parent.childNodes.splice(position, 0, after);
+ }
+ node.remove?.(); node.parentNode = parent; parent.childNodes.splice(position, 0, node);
+ }
+ }
+ const selection = {ranges: [], get rangeCount() { return this.ranges.length; },
+ get anchorNode() { return this.ranges[0]?.startContainer || null; },
+ getRangeAt: index => selection.ranges[index], removeAllRanges() { this.ranges = []; }, addRange(range) { this.ranges.push(range); }};
+ document.createRange = () => new Range();
+ const window = {getSelection: () => selection};
+ addEvents(window);
+ const context = vm.createContext({document, window, Node: {ELEMENT_NODE: 1, TEXT_NODE: 3},
+ TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, DataView, atob, btoa, Blob,
+ DecompressionStream, ReadableStream, Date, console,
+ localStorage: {getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key)},
+ prompt: () => { if (denyDialogs) throw new Error('prompt() is not supported.'); return prompts.shift() ?? null; },
+ confirm: () => { if (denyDialogs) throw new Error('confirm() is not supported.'); return true; },
+ alert: message => alerts.push(message),
+ setTimeout: callback => { const id = ++timer; tasks.set(id, callback); return id; },
+ clearTimeout: id => tasks.delete(id),
+ navigator: {clipboard: {writeText: async () => {}}},
+ URL: {createObjectURL: blob => { blobs.push(blob); return 'blob:fixture/' + blobs.length; }, revokeObjectURL() {}},
+ });
+ let mathField = null;
+ if (includeVisualMath) {
+ // Instrument only the external custom element boundary. Production
+ // adapter, palette buttons, modal and workspace handlers remain real.
+ const field = ids.get('formulaMathField');
+ mathField = {writes: [], inserts: []};
+ let fieldValue = '';
+ Object.defineProperty(field, 'value', {configurable: true,
+ get: () => fieldValue,
+ set(value) { fieldValue = String(value); mathField.writes.push(fieldValue); field.lastOffset = fieldValue.length + 5; },
+ });
+ field.insert = (latex, options) => {
+ mathField.inserts.push({latex, options: {...options}});
+ field.value += latex;
+ };
+ context.MathfieldElement = class MathfieldElement {};
+ context.customElements = {get: name => ['math-field', 'math-span'].includes(name) ? context.MathfieldElement : undefined};
+ }
+ if (denyStorage) Object.defineProperty(context, 'localStorage', {
+ get() { const error = new Error('Access to localStorage is denied'); error.name = 'SecurityError'; throw error; },
+ });
+ const loaded = [...sources];
+ if (includeVisualMath) loaded.unshift(['visual-math.js', fs.readFileSync(new URL('../../visual-math.js', import.meta.url), 'utf8')]);
+ if (includeVisualReferences) for (const name of ['visual-references.js', 'visual-references-ui.js']) {
+ loaded.splice(loaded.length - 1, 0, [name, fs.readFileSync(new URL('../../' + name, import.meta.url), 'utf8')]);
+ }
+ for (const [filename, source] of loaded) vm.runInContext(source, context, {filename});
+ await emit(document, 'DOMContentLoaded');
+ const refresh = () => { for (const node of root.querySelectorAll('*')) enhance(node); };
+ refresh();
+ return {context, app: context.visualWorkspace, document, ids, storage, prompts, alerts, blobs, emit, dispatch,
+ refresh, selection, mathField,
+ setCursor(node, offset) { const range = new Range(); range.setStart(node, offset); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); },
+ node: id => ids.get(id), click: id => ids.get(id).click(),
+ async input(id, value) {
+ // Source-oriented scenarios explicitly open the source editor, as a
+ // real user must now do from the default visual editing surface.
+ if (id === 'sourceArea') await ids.get('modeSource').click();
+ ids.get(id).value = value; await emit(ids.get(id), 'input');
+ },
+ async flushTimers() {
+ const pending = [...tasks.values()]; tasks.clear();
+ for (const callback of pending) await callback();
+ },
+ };
+}
+
diff --git a/tests/pdf-preview.test.mjs b/tests/pdf-preview.test.mjs
new file mode 100644
index 0000000..5c40f20
--- /dev/null
+++ b/tests/pdf-preview.test.mjs
@@ -0,0 +1,78 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {mkdtemp, readdir, rm, writeFile} from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import {getPdfPreviewInfo, renderPdfPreviewPage, PREVIEW_LIMITS} from '../pdf-preview.mjs';
+
+const pdf = Buffer.from('%PDF-1.7\nfixture\n%%EOF');
+const engine = {available: true, executable: '/configured/gs'};
+const png = Buffer.alloc(24);
+Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png);
+png.write('IHDR', 12); png.writeUInt32BE(1200, 16); png.writeUInt32BE(1600, 20);
+async function directory(t) {
+ const folder = await mkdtemp(path.join(os.tmpdir(), 'visual-preview-test-'));
+ t.after(() => rm(folder, {recursive: true, force: true}));
+ return folder;
+}
+
+test('preview metadata counts pages with a fixed program and cleans the staged PDF', async t => {
+ const temporaryParent = await directory(t);
+ const info = await getPdfPreviewInfo(pdf, {engine, temporaryParent, runner: async (_, args, options) => {
+ assert.ok(args.includes('-dSAFER'));
+ assert.ok(args.includes('-dNODISPLAY'));
+ assert.ok(args.some(arg => arg.startsWith('-sPDFFile=')));
+ assert.equal(args.at(-1), 'PDFFile (r) file runpdfbegin (VISUALLATEX_PAGE_COUNT:) print pdfpagecount = runpdfend quit');
+ assert.equal(options.env.GS_OPTIONS, undefined);
+ return {code: 0, log: 'VISUALLATEX_PAGE_COUNT:2\n'};
+ }});
+ assert.deepEqual(info, {available: true, pageCount: 2, maxPages: 100, width: 1200, height: 1600});
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('one PDF page is rendered on a fixed pixel canvas without clipping the original page', async t => {
+ const temporaryParent = await directory(t);
+ const result = await renderPdfPreviewPage(pdf, 2, {engine, pageCount: 2, temporaryParent, runner: async (_, args, options) => {
+ for (const flag of ['-dSAFER', '-sDEVICE=png16m', '-g1200x1600', '-dFIXEDMEDIA', '-dPDFFitPage', '-dFirstPage=2', '-dLastPage=2']) assert.ok(args.includes(flag));
+ assert.ok(!args.includes('-dNOSAFER'));
+ await writeFile(options.output, png); return {code: 0};
+ }});
+ assert.deepEqual(result, png);
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('missing engines, invalid input and page limits do not run a preview process', async t => {
+ const temporaryParent = await directory(t);
+ let calls = 0;
+ const options = {engine, temporaryParent, pageCount: 1000, runner: async () => { calls++; }};
+ assert.equal((await getPdfPreviewInfo(pdf)).available, false);
+ for (const page of [0, -1, 1.5, 101, NaN]) await assert.rejects(renderPdfPreviewPage(pdf, page, options), {code: 'PREVIEW_PAGE_RANGE'});
+ await assert.rejects(renderPdfPreviewPage(Buffer.from('not pdf'), 1, options), {code: 'PREVIEW_INVALID_PDF'});
+ assert.equal(calls, 0);
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('invalid page counts, engine errors and malformed or oversized images are rejected and cleaned', async t => {
+ const temporaryParent = await directory(t);
+ await assert.rejects(getPdfPreviewInfo(pdf, {engine, temporaryParent, runner: async () => ({code: 0, log: 'Not a count'})}), {code: 'PREVIEW_INVALID_PAGE_COUNT'});
+ await assert.rejects(getPdfPreviewInfo(pdf, {engine, temporaryParent, runner: async () => ({code: 1, log: 'invalid PDF'})}), {code: 'PREVIEW_FAILED'});
+ for (const bytes of [Buffer.from('not PNG'), Buffer.alloc(PREVIEW_LIMITS.maxImageBytes + 1)]) {
+ await assert.rejects(renderPdfPreviewPage(pdf, 1, {engine, pageCount: 1, temporaryParent,
+ runner: async (_, __, options) => { await writeFile(options.output, bytes); return {code: 0}; }}));
+ }
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('timeout and cancellation stop preview generation and remove all temporary files', async t => {
+ const temporaryParent = await directory(t);
+ for (const mode of ['timeout', 'cancel']) {
+ const controller = new AbortController();
+ const pending = getPdfPreviewInfo(pdf, {engine, temporaryParent, signal: controller.signal, timeoutMs: mode === 'timeout' ? 20 : 2000,
+ runner: (_, __, {signal}) => new Promise((_, reject) => {
+ signal.addEventListener('abort', () => reject(signal.reason), {once: true});
+ if (mode === 'cancel') controller.abort();
+ })});
+ await assert.rejects(pending, {code: mode === 'timeout' ? 'PREVIEW_TIMEOUT' : 'PREVIEW_CANCELLED'});
+ assert.deepEqual(await readdir(temporaryParent), []);
+ }
+});
diff --git a/tests/preview.test.mjs b/tests/preview.test.mjs
new file mode 100644
index 0000000..be3756b
--- /dev/null
+++ b/tests/preview.test.mjs
@@ -0,0 +1,494 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+
+const code = fs.readFileSync(new URL('../preview.js', import.meta.url), 'utf8');
+const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+const engines = {latex: {available: true, engine: 'tectonic', label: 'Tectonic'},
+ postscript: {available: true, engine: 'ghostscript', label: 'Ghostscript'}};
+const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body});
+const pdf = () => ({ok: true, blob: async () => new Blob(['%PDF-1.4\nfixture\n%%EOF'], {type: 'application/pdf'})});
+const makeRequest = () => ({kind: 'latex', mainPath: 'main.tex', project: {name: 'Project', mainPath: 'main.tex', activePath: 'main.tex',
+ files: [{path: 'main.tex', kind: 'text', text: '\\documentclass{article}\n\\begin{document}Original\\end{document}'},
+ {path: 'figure.eps', kind: 'binary', base64: 'xdDTxgABAv8='}]}});
+const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
+
+function fixture(options = {}) {
+ const calls = [], created = [], revoked = [], downloads = [];
+ let request = makeRequest();
+ let job = 0;
+ const controls = {status: 'succeeded', log: 'engine log', error: 'Undefined command'};
+ const urls = {createObjectURL(blob) { const url = 'blob:fixture/' + (created.length + 1); created.push({url, blob}); return url; },
+ revokeObjectURL: url => revoked.push(url)};
+ const fetcher = async (url, init = {}) => {
+ calls.push({url, init});
+ const custom = await options.route?.(url, init, {calls, controls});
+ if (custom !== undefined) return custom;
+ if (url === '/api/status') return json({token: 'test-token', engines});
+ if (url === '/api/compile') return json({id: 'job-' + ++job}, 202);
+ if (init.method === 'DELETE') return json({state: 'cancelled'});
+ if (url.endsWith('/pdf')) return pdf();
+ if (url.endsWith('/preview')) return json({available: false, reason: 'Ghostscript not configured'});
+ if (url.startsWith('/api/jobs/')) return json({state: controls.status, log: controls.log, error: controls.error, engine: 'Tectonic', pdfUrl: url + '/pdf'});
+ throw new Error('Unexpected request ' + url);
+ };
+ const context = vm.createContext({Blob, AbortController, setTimeout, clearTimeout, URL: urls, fetch: fetcher, location: {protocol: 'http:'}, ...options.context});
+ vm.runInContext(code, context);
+ const controller = new context.CompilationController({getRequest: () => structuredClone(request), fetch: fetcher, urls,
+ sleep: options.sleep || (async () => {}), protocol: options.protocol || 'http:', maxWait: options.maxWait,
+ download: (url, name) => downloads.push({url, name})});
+ return {controller, context, controls, calls, created, revoked, downloads,
+ get request() { return request; }, set request(next) { request = next; }};
+}
+
+test('compile targets include binary EPS but not unrelated resources', () => {
+ const f = fixture();
+ assert.equal(f.context.compilationKindForFile({path: 'main.tex', kind: 'text'}), 'latex');
+ assert.equal(f.context.compilationKindForFile({path: 'custom.ltx', kind: 'text'}), 'latex');
+ assert.equal(f.context.compilationKindForFile({path: 'figure.EPS', kind: 'binary'}), 'postscript');
+ assert.equal(f.context.compilationKindForFile({path: 'image.pdf', kind: 'binary'}), null);
+});
+
+test('file mode makes no requests and explains how to enable local compilation', async () => {
+ const f = fixture({protocol: 'file:'});
+ assert.equal(await f.controller.connect(), false);
+ assert.equal(f.controller.phase, 'unavailable');
+ assert.match(f.controller.message, /node server\.mjs/);
+ assert.equal(await f.controller.compile(), false);
+ assert.equal(f.calls.length, 0);
+});
+
+test('successful compilation sends an immutable project snapshot and exports the current PDF', async () => {
+ const f = fixture();
+ const original = structuredClone(f.request);
+ assert.equal(await f.controller.connect(), true);
+ assert.equal(await f.controller.compile(), true);
+ assert.equal(f.controller.phase, 'succeeded');
+ const submitted = f.calls.find(call => call.url === '/api/compile');
+ assert.deepEqual(JSON.parse(submitted.init.body), original);
+ assert.equal(submitted.init.headers['Content-Type'], 'application/json');
+ for (const call of f.calls.filter(call => call.url !== '/api/status')) assert.equal(call.init.headers['X-VisualLaTeX-Token'], 'test-token');
+ assert.deepEqual(f.request, original);
+ assert.equal(f.controller.log, 'engine log');
+ assert.equal(await f.controller.exportPdf(), true);
+ assert.deepEqual(f.downloads, [{url: 'blob:fixture/1', name: 'main.pdf'}]);
+ assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 1);
+});
+
+test('editing a dependency makes PDF stale and export recompiles before downloading', async () => {
+ const f = fixture();
+ await f.controller.connect(); await f.controller.compile();
+ f.request.project.files[1].base64 = 'bmV3';
+ f.controller.markChanged();
+ assert.equal(f.controller.stale, true);
+ assert.equal(await f.controller.exportPdf(), true);
+ assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 2);
+ assert.deepEqual(f.downloads, [{url: 'blob:fixture/2', name: 'main.pdf'}]);
+ assert.deepEqual(f.revoked, ['blob:fixture/1']);
+ assert.equal(f.controller.stale, false);
+});
+
+test('changing only project name or active editor does not invalidate identical compilation input', async () => {
+ const f = fixture();
+ await f.controller.connect(); await f.controller.compile();
+ f.request.project.name = 'Renamed'; f.request.project.activePath = 'figure.eps';
+ f.controller.markChanged();
+ assert.equal(f.controller.stale, false);
+});
+
+test('compile failure keeps prior preview and diagnostic log but never exports stale source', async () => {
+ const f = fixture();
+ await f.controller.connect(); await f.controller.compile();
+ f.request.project.files[0].text += '\\invalidcommand';
+ f.controls.status = 'failed'; f.controls.log = 'main.tex:4: Undefined command';
+ assert.equal(await f.controller.exportPdf(), false);
+ assert.equal(f.controller.result.url, 'blob:fixture/1');
+ assert.equal(f.controller.phase, 'failed');
+ assert.equal(f.controller.stale, true);
+ assert.match(f.controller.log, /main\.tex:4/);
+ assert.equal(f.downloads.length, 0);
+ assert.equal(f.revoked.length, 0);
+});
+
+test('source changes while PDF bytes arrive prevent that result from replacing the preview', async () => {
+ let f;
+ f = fixture({route: async url => {
+ if (url.endsWith('/pdf')) return {ok: true, blob: async () => {
+ f.request.project.files[0].text = 'newer edit';
+ return new Blob(['%PDF-1.4']);
+ }};
+ }});
+ await f.controller.connect();
+ assert.equal(await f.controller.compile(), false);
+ assert.equal(f.controller.phase, 'stale');
+ assert.equal(f.controller.result, null);
+ assert.equal(f.created.length, 0);
+});
+
+test('cancellation aborts the client, deletes a known job and ignores late polling success', async () => {
+ const progress = deferred();
+ const ready = deferred();
+ const f = fixture({route: async (url, init) => {
+ if (url === '/api/jobs/job-1' && init.method !== 'DELETE') { ready.resolve(); return progress.promise; }
+ }});
+ await f.controller.connect();
+ const running = f.controller.compile();
+ await ready.promise;
+ const abort = f.controller.abort;
+ assert.equal(await f.controller.connect(), false, 'connection retry cannot reset a running job');
+ await f.controller.cancel();
+ assert.equal(abort.signal.aborted, true);
+ progress.resolve(json({state: 'succeeded'}));
+ assert.equal(await running, false);
+ assert.equal(f.controller.phase, 'cancelled');
+ assert.ok(f.calls.some(call => call.url === '/api/jobs/job-1' && call.init.method === 'DELETE'));
+ assert.equal(f.created.length, 0);
+});
+
+test('cancellation before POST resolves deletes a late job id without starting another preview', async () => {
+ const response = deferred(), ready = deferred();
+ const f = fixture({route: async url => { if (url === '/api/compile') { ready.resolve(); return response.promise; } }});
+ await f.controller.connect();
+ const running = f.controller.compile();
+ await ready.promise;
+ await f.controller.cancel();
+ response.resolve(json({id: 'late-job'}, 202));
+ assert.equal(await running, false);
+ assert.ok(f.calls.some(call => call.url === '/api/jobs/late-job' && call.init.method === 'DELETE'));
+ assert.equal(f.created.length, 0);
+});
+
+test('untrusted PDF URLs are rejected rather than fetched with the session token', async () => {
+ const f = fixture({route: async url => {
+ if (url === '/api/jobs/job-1') return json({state: 'succeeded', pdfUrl: 'https://external.example/pdf'});
+ }});
+ await f.controller.connect();
+ assert.equal(await f.controller.compile(), false);
+ assert.match(f.controller.message, /PDF 路径无效/);
+ assert.equal(f.calls.some(call => call.url.startsWith('https:')), false);
+});
+
+test('bounded polling cancels the server job when the wait expires', async () => {
+ const f = fixture({maxWait: -1});
+ f.controls.status = 'running';
+ await f.controller.connect();
+ assert.equal(await f.controller.compile(), false);
+ assert.match(f.controller.message, /超时/);
+ assert.ok(f.calls.some(call => call.init.method === 'DELETE'));
+});
+
+test('a polling network failure cancels its known server job and allows a fresh compilation', async () => {
+ let failPolling = true;
+ const f = fixture({route: async (url, init) => {
+ if (url === '/api/jobs/job-1' && init.method !== 'DELETE' && failPolling) {
+ failPolling = false;
+ throw new TypeError('Failed to fetch progress');
+ }
+ }});
+ await f.controller.connect();
+ assert.equal(await f.controller.compile(), false);
+ assert.equal(f.controller.phase, 'failed');
+ assert.equal(f.controller.message, 'Failed to fetch progress');
+ assert.equal(f.controller.jobId, null);
+ assert.ok(f.calls.some(call => call.url === '/api/jobs/job-1' && call.init.method === 'DELETE'));
+ assert.equal(await f.controller.compile(), true);
+ assert.equal(f.controller.phase, 'succeeded');
+ assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 2);
+ assert.equal(f.created.length, 1);
+});
+
+test('late failure cleanup cannot clear the id or abort controller of a newer compilation', async () => {
+ const cleanup = deferred(), cleanupStarted = deferred(), progress = deferred(), progressStarted = deferred();
+ const f = fixture({route: async (url, init) => {
+ if (url === '/api/jobs/job-1' && init.method !== 'DELETE') throw new Error('Polling disconnected');
+ if (url === '/api/jobs/job-1' && init.method === 'DELETE') { cleanupStarted.resolve(); return cleanup.promise; }
+ if (url === '/api/jobs/job-2' && init.method !== 'DELETE') { progressStarted.resolve(); return progress.promise; }
+ }});
+ await f.controller.connect();
+ const first = f.controller.compile();
+ await cleanupStarted.promise;
+ assert.equal(f.controller.message, 'Polling disconnected');
+ const second = f.controller.compile();
+ await progressStarted.promise;
+ const abort = f.controller.abort;
+ cleanup.resolve(json({state: 'cancelled'}));
+ assert.equal(await first, false);
+ assert.equal(f.controller.jobId, 'job-2');
+ assert.equal(f.controller.abort, abort);
+ assert.equal(f.controller.phase, 'running');
+ progress.resolve(json({state: 'succeeded', pdfUrl: '/api/jobs/job-2/pdf'}));
+ assert.equal(await second, true);
+});
+
+test('replacing a project or destroying its view releases object URLs and pending work', async () => {
+ const f = fixture();
+ await f.controller.connect(); await f.controller.compile();
+ f.controller.clear();
+ assert.equal(f.controller.result, null);
+ assert.deepEqual(f.revoked, ['blob:fixture/1']);
+ await f.controller.compile();
+ f.controller.destroy();
+ assert.deepEqual(f.revoked, ['blob:fixture/1', 'blob:fixture/2']);
+ assert.equal(await f.controller.compile(), false);
+});
+
+test('missing engines and HTTP errors give actionable errors without mutating the project', async () => {
+ const f = fixture({route: async url => url === '/api/status' ? json({token: 'token', engines: {latex: {available: false}}}) : undefined});
+ const original = structuredClone(f.request);
+ await f.controller.connect();
+ assert.equal(await f.controller.compile(), false);
+ assert.match(f.controller.message, /Tectonic|XeLaTeX/);
+ assert.deepEqual(f.request, original);
+ assert.equal(f.calls.length, 1);
+ const disconnected = fixture({route: async () => json({error: 'Service unavailable'}, 503)});
+ assert.equal(await disconnected.controller.connect(), false);
+ assert.equal(disconnected.controller.phase, 'unavailable');
+});
+
+test('preview markup has every production control, accessible labels and initially hidden PDF', () => {
+ const ids = new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]));
+ for (const match of code.matchAll(/\$\('([^']+)'\)/g)) assert.ok(ids.has(match[1]), match[1]);
+ assert.match(html, /
]+aria-label=/);
+ assert.match(html, /