-
-
- 可视化编辑区
-
-
-
- 欢迎使用 VisualLaTeX
-这是一个纯前端、零依赖、完全离线的 LaTeX 轻编辑器,适合对已有论文做快速小修改。
+
+
+
+
+
-
-
+
-
+ LaTeX
+ main.tex
+
+
+
+
+
+ 三步上手
--
-
- 点击工具栏的「导入 .tex」,选择本地的 LaTeX 文件 -
- 在左侧修改文字、标题与列表,双击已有公式可打开编辑 -
- 点击「下载文件」导出修改后的 .tex,或复制代码到剪贴板 -
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ 仅查找当前文件
+
+
+ 公式示例
-行内公式示例:$E=mc^2$
+
+
- 1+ +
$$\int_{a}^{b} f(x) \, dx = F(b) - F(a)$$
+
+
- 编辑文字与格式;引用、导言区和任意命令都可切回源码修改。
+
+ 小提示
-表格、参考文献、代码清单等复杂结构会以灰色「原样保留块」展示,可以直接编辑其中文本,导出时原样输出,不会丢失内容。修改内容会自动保存为草稿。
+
+ ↳
+
- 项目资源
+此文件会随项目一起导出,保持原始内容。
-
+ LaTeX 代码预览
-
-
+ 行 1,列 1
+ LaTeX / PostScript 源码编辑 · 无需联网
+
+
+
+
+
-
-
-
+
+
-
+
+
+
+
+
+
-
- 就绪
-
-
- 草稿自动保存
-
-
+
+
+
+
+
+
-
-
}}';
+ f.app.changed(false);
+ f.app.switchFile('main.tex');
+ const entries = f.app.exportEntries();
+ assert.equal(new TextDecoder().decode(entries.find(file => file.path === 'references.bib').bytes), '@book{new, title={
}}');
+ assert.deepEqual(Array.from(entries.find(file => file.path === 'images/a.bin').bytes), Array.from(binary));
+ assert.equal(f.visual.querySelectorAll('img').length, 0);
+});
+
+test('save failure retains prior atomic project payload and current source for recovery', () => {
+ const f = fixture([textFile('main.tex', tex('Saved.'))]);
+ assert.equal(f.app.persist(), true);
+ const saved = f.data.get('visuallatex_workspace_v1');
+ f.ui.sourceArea.value = '\\cite{unfinished-new';
+ f.controls.failWrites = true;
+ assert.equal(f.app.persist(), false);
+ assert.equal(f.data.get('visuallatex_workspace_v1'), saved);
+ assert.equal(f.app.model.get().text, '\\cite{unfinished-new');
+ assert.match(f.ui.saveState.textContent, /保存失败/);
+});
+
+test('blocked recovery never overwrites existing storage until an explicit replacement', () => {
+ const f = fixture([textFile('main.tex', tex('Placeholder.'))]);
+ f.data.set('visuallatex_workspace_v1', '{broken older project');
+ f.app.recoveryBlocked = true;
+ assert.equal(f.app.persist(), false);
+ assert.equal(f.data.get('visuallatex_workspace_v1'), '{broken older project');
+ f.app.replaceProject(new f.context.ProjectModel({files: [textFile('new.ps', '%!PS\nnew')]}));
+ assert.equal(f.app.recoveryBlocked, false);
+ assert.equal(f.context.ProjectModel.deserialize(f.data.get('visuallatex_workspace_v1')).activePath, 'new.ps');
+});
+
+test('PostScript Latin-1 and binary EPS imports retain original bytes', () => {
+ const f = fixture([]);
+ const psBytes = Uint8Array.from([37, 33, 80, 83, 10, 37, 32, 233, 10]);
+ const ps = f.context.workspaceFile('drawing.ps', psBytes);
+ assert.equal(ps.kind, 'text');
+ assert.equal(ps.encoding, 'latin1');
+ assert.deepEqual(Array.from(f.context.workspaceBytes(ps)), Array.from(psBytes));
+ const epsBytes = Uint8Array.from([0xc5, 0xd0, 0xd3, 0xc6, 0, 1, 2, 255]);
+ const eps = f.context.workspaceFile('drawing.eps', epsBytes);
+ assert.equal(eps.kind, 'binary');
+ assert.deepEqual(Array.from(f.context.workspaceBytes(eps)), Array.from(epsBytes));
+});
+
+test('custom project source extensions remain editable while binary assets preserve bytes', () => {
+ const f = fixture([]);
+ const bytes = new TextEncoder().encode('\\newcommand{\\custom}{Editable source}\r\n');
+ for (const path of ['drawing.tikz', 'document.ltx', 'style.bbx', 'configuration', 'custom.source']) {
+ const file = f.context.workspaceFile(path, bytes);
+ assert.equal(file.kind, 'text', path);
+ assert.deepEqual(Array.from(f.context.workspaceBytes(file)), Array.from(bytes));
+ }
+ const asset = f.context.workspaceFile('asset.pdf', new TextEncoder().encode('%PDF-1.7\nASCII contents'));
+ assert.equal(asset.kind, 'binary');
+ const unknown = Uint8Array.of(1, 2, 3, 0, 127);
+ assert.equal(f.context.workspaceFile('binary.data', unknown).kind, 'binary');
+});
+
+test('clicking a later repeated citation locates that token, including inside formatting', () => {
+ const source = tex('% hidden \\cite{same}\nFirst \\cite{same}.\n\nSecond \\textbf{also \\cite{same}}.').replace(/\n/g, '\r\n');
+ const f = fixture([textFile('main.tex', source)]);
+ f.app.setMode('visual');
+ const tokens = f.visual.querySelectorAll('.tex-cmd').filter(node => node.textContent === '\\cite{same}');
+ assert.equal(tokens.length, 2);
+ const offset = f.context.locateWorkspaceToken(f.visual, tokens[1], f.app.model.get().text);
+ assert.equal(offset, source.replace(/\r\n/g, '\n').lastIndexOf('\\cite{same}'));
+ assert.equal(f.app.model.get().text, source);
+});
+
+test('project export at file-count limit retains all files without an extra manifest', () => {
+ const files = Array.from({length: 500}, (_, index) => textFile('file' + index + '.txt', 'content'));
+ const f = fixture(files);
+ assert.equal(f.app.exportEntries().length, 500);
+});
+
+test('project export preserves user files whose names differ only in manifest casing', () => {
+ const path = '.VISUAL-WORKSPACE/project.json';
+ const f = fixture([textFile('main.tex', tex('Text.')), textFile(path, '{"user":"content"}')]);
+ const entries = f.app.exportEntries();
+ assert.equal(entries.length, 2);
+ assert.equal(new TextDecoder().decode(entries.find(file => file.path === path).bytes), '{"user":"content"}');
+});
diff --git a/tests/workspace-markup.test.mjs b/tests/workspace-markup.test.mjs
new file mode 100644
index 0000000..35a9823
--- /dev/null
+++ b/tests/workspace-markup.test.mjs
@@ -0,0 +1,75 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+
+const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+const controller = fs.readFileSync(new URL('../workspace.js', import.meta.url), 'utf8');
+const css = fs.readFileSync(new URL('../workspace.css', import.meta.url), 'utf8');
+const tags = [...html.matchAll(/<([a-z][a-z\d-]*)\b([^>]*\bid="([^"]+)"[^>]*)>/gi)];
+const ids = new Map(tags.map(match => [match[3], {tag: match[1].toLowerCase(), attributes: match[2]}]));
+
+test('all direct workspace lookups and event bindings resolve to unique DOM IDs', () => {
+ assert.equal(ids.size, tags.length, 'each id must occur exactly once');
+ const lookups = [...controller.matchAll(/\$\(\s*(['"])([^'"]+)\1\s*\)/g)].map(match => match[2]);
+ const bindings = [...controller.matchAll(/\bbind\(\s*(['"])([^'"]+)\1\s*,/g)].map(match => match[2]);
+ assert.ok(lookups.length > 20, 'inspect actual controller references');
+ assert.ok(bindings.length > 20, 'inspect actual controller event bindings');
+ for (const id of new Set([...lookups, ...bindings])) assert.ok(ids.has(id), `missing workspace control #${id}`);
+});
+
+test('dynamic toolbar arrays and project/editor controls have real markup', () => {
+ const knownDynamic = [
+ 'btnH1', 'btnH2', 'btnH3', 'btnBold', 'btnItalic', 'btnCenter', 'btnUl', 'btnOl',
+ 'btnInlineFormula', 'btnBlockFormula', 'btnImage', 'btnInsertCitation', 'btnInsertReference',
+ 'btnInsertLabel', 'btnCopy', 'btnFind', 'renameFile', 'deleteFile', 'setMainFile', 'btnDownload',
+ 'btnEncoding', 'btnFiles', 'btnOutline', 'newFile', 'btnAddBibEntry', 'projectName', 'fileTree',
+ 'sourceArea', 'lineNumbers', 'sourcePanel', 'visualPanel', 'assetPanel', 'editorArea',
+ 'importFiles', 'importFolder', 'importProject', 'outlineList', 'referenceList', 'saveState',
+ ];
+ const discoveredDynamic = [];
+ for (const array of controller.matchAll(/for\s*\(const\s+id\s+of\s*\[([^\]]+)\]/g)) {
+ for (const literal of array[1].matchAll(/['"]([^'"]+)['"]/g)) discoveredDynamic.push(literal[1]);
+ }
+ for (const id of new Set([...knownDynamic, ...discoveredDynamic])) assert.ok(ids.has(id), `missing dynamic control #${id}`);
+ assert.equal(ids.get('btnEncoding').tag, 'button');
+ assert.match(html, /id="btnEncoding"[^>]*>UTF-8<\/button>/);
+});
+
+test('offline scripts and stylesheet overrides load in dependency order', () => {
+ const scripts = [...html.matchAll(/\n\\end{document}\n';
+ await f.input('sourceArea', source);
+ assert.equal(f.node('sourcePanel').querySelectorAll('img').length, 0);
+ assert.equal(f.node('sourcePanel').querySelectorAll('script').length, 0);
+ await f.click('modeVisual');
+ assert.equal(f.node('editorArea').querySelectorAll('img').length, 0);
+ assert.equal(f.node('editorArea').querySelectorAll('script').length, 0);
+ await f.click('modeSource');
+ assert.equal(f.app.model.get().text, source);
+});
+
+test('ZIP export and project import preserve all edited files and replace the workspace only after validation', async () => {
+ const f = await fixture();
+ const select = async path => {
+ const button = f.node('fileTree').querySelectorAll('button').find(node => node.textContent.includes(path));
+ assert.ok(button, 'file tree contains ' + path);
+ await button.click();
+ };
+ const section = String.raw`\section{Exported chapter}\label{sec:export}` + '\n';
+ f.prompts.push('sections/chapter.tex');
+ await f.click('newFile');
+ await f.input('sourceArea', section);
+ const bib = '@book{export2026,\n title = {Edited bibliography},\n year = {2026}\n}\n';
+ await select('references.bib');
+ await f.input('sourceArea', bib);
+ const ps = '%!PS-Adobe-3.0\n(Edited PostScript) show\nshowpage\n';
+ await select('drawing.ps');
+ await f.input('sourceArea', ps);
+ await f.click('setMainFile');
+ const binary = Uint8Array.of(137, 80, 78, 71, 0, 255, 128, 13, 10);
+ f.node('importFiles').files = [{name: 'figure.png', size: binary.length, arrayBuffer: async () => binary.slice().buffer}];
+ await f.emit(f.node('importFiles'), 'change');
+ await select('main.tex');
+ const main = String.raw`\documentclass{article}
+\begin{document}
+\input{sections/chapter}
+\cite{export2026}
+\end{document}
+`;
+ await f.input('sourceArea', main);
+ f.node('projectName').value = 'Round-trip project';
+ await f.emit(f.node('projectName'), 'change');
+ await f.click('btnExportProject');
+ assert.equal(f.blobs.length, 1);
+ const archive = f.blobs[0];
+ assert.equal(archive.type, 'application/zip');
+ const archiveBytes = await archive.arrayBuffer();
+
+ await f.click('btnNewProject');
+ await f.input('sourceArea', 'Keep this current draft unless project parsing succeeds.');
+ const currentModel = f.app.model;
+ const current = currentModel.serialize();
+ f.node('importProject').files = [{name: 'broken.zip', size: 3, arrayBuffer: async () => Uint8Array.of(0, 1, 2).buffer}];
+ await f.emit(f.node('importProject'), 'change');
+ assert.equal(f.app.model, currentModel);
+ assert.equal(f.app.model.serialize(), current);
+ assert.match(f.node('statusText').textContent, /压缩包|ZIP|文件/);
+
+ let finishReading;
+ f.node('importProject').files = [{name: 'round-trip.zip', size: archive.size,
+ arrayBuffer: () => new Promise(resolve => { finishReading = resolve; })}];
+ const importing = f.emit(f.node('importProject'), 'change');
+ assert.equal(f.app.model, currentModel, 'reading the archive must not replace the current project');
+ assert.equal(f.app.model.serialize(), current);
+ finishReading(archiveBytes);
+ await importing;
+ assert.notEqual(f.app.model, currentModel);
+ assert.equal(f.app.model.name, 'Round-trip project');
+ assert.equal(f.app.model.mainPath, 'drawing.ps');
+ assert.equal(f.app.model.activePath, 'main.tex');
+ assert.equal(f.node('projectName').value, 'Round-trip project');
+ assert.equal(f.app.model.get('main.tex').text, main);
+ assert.equal(f.app.model.get('sections/chapter.tex').text, section);
+ assert.equal(f.app.model.get('references.bib').text, bib);
+ assert.equal(f.app.model.get('drawing.ps').text, ps);
+ const restoredBinary = f.app.model.get('figure.png');
+ assert.equal(restoredBinary.kind, 'binary');
+ assert.deepEqual(Uint8Array.from(atob(restoredBinary.base64), character => character.charCodeAt(0)), binary);
+ assert.equal(f.app.model.list().length, 5);
+});
+
+test('reference controls insert editable commands and bibliography entries reject duplicate keys', async () => {
+ const f = await fixture();
+ await f.input('sourceArea', 'References: ');
+ for (const [id, key] of [['btnInsertCitation', 'example2026,new2026'], ['btnInsertReference', 'sec:result'], ['btnInsertLabel', 'sec:result']]) {
+ f.prompts.push(key);
+ await f.click(id);
+ }
+ const commands = String.raw`References: \cite{example2026,new2026}\ref{sec:result}\label{sec:result}`;
+ assert.equal(f.app.model.get().text, commands);
+ assert.equal(f.node('sourceArea').value, commands);
+ const oldBib = f.app.model.get('references.bib').text;
+ f.prompts.push('new2026');
+ await f.click('btnAddBibEntry');
+ assert.equal(f.app.model.activePath, 'references.bib');
+ assert.equal(f.app.mode, 'source');
+ const entry = '\n@article{new2026,\n author = {},\n title = {},\n journal = {},\n year = {}\n}\n';
+ assert.equal(f.app.model.get().text, oldBib + entry);
+ await f.input('sourceArea', f.node('sourceArea').value.replace('title = {},', 'title = {An editable entry},'));
+ assert.match(f.app.model.get().text, /title = \{An editable entry\}/);
+ const unchanged = f.app.model.serialize();
+ f.prompts.push('new2026');
+ await f.click('btnAddBibEntry');
+ assert.match(f.node('statusText').textContent, /文献键已存在/);
+ assert.equal(f.app.model.serialize(), unchanged);
+ assert.equal(f.app.model.get('main.tex').text, commands);
+});
+
+test('case-insensitive find and replace use original offsets after Unicode case expansion', async () => {
+ const f = await fixture();
+ await f.input('sourceArea', 'İ X');
+ await f.click('btnFind');
+ f.node('findText').value = 'X';
+ f.node('replaceText').value = '$&';
+ f.node('sourceArea').setSelectionRange(0, 0);
+ await f.click('btnFindNext');
+ assert.equal(f.node('sourceArea').selectionStart, 2);
+ assert.equal(f.node('sourceArea').selectionEnd, 3);
+ await f.click('btnReplace');
+ assert.equal(f.app.model.get().text, 'İ $&');
+ await f.input('sourceArea', 'İ X x');
+ await f.click('btnReplaceAll');
+ assert.equal(f.app.model.get().text, 'İ $& $&');
+ assert.equal(f.node('findStatus').textContent, '已替换 2 处');
+ await f.click('btnUndo');
+ assert.equal(f.app.model.get().text, 'İ X x');
+ f.node('findCase').checked = true;
+ await f.click('btnReplaceAll');
+ assert.equal(f.app.model.get().text, 'İ $& x');
+ assert.equal(f.node('findStatus').textContent, '已替换 1 处');
+});
+
+test('find treats regular-expression symbols and replacement dollar tokens as literal text', async () => {
+ const f = await fixture();
+ const token = '[a].*+?^${}()|\\';
+ const upper = token.replace('a', 'A');
+ await f.input('sourceArea', token + ' / ' + upper + ' / unrelated');
+ f.node('findText').value = token;
+ f.node('replaceText').value = '$&';
+ f.node('sourceArea').setSelectionRange(0, 0);
+ await f.click('btnFindNext');
+ assert.equal(f.node('sourceArea').selectionStart, 0);
+ assert.equal(f.node('sourceArea').selectionEnd, token.length);
+ await f.click('btnReplace');
+ assert.equal(f.app.model.get().text, '$& / ' + upper + ' / unrelated');
+ f.node('replaceText').value = '$& $1 $$';
+ await f.click('btnReplaceAll');
+ assert.equal(f.app.model.get().text, '$& / $& $1 $$ / unrelated');
+ assert.equal(f.node('findStatus').textContent, '已替换 1 处');
+});
+
+test('creating the first file after deleting all files retains LaTeX and PostScript templates', async () => {
+ for (const path of ['main.tex', 'drawing.ps']) {
+ const f = await fixture();
+ while (f.app.model.list().length) await f.click('deleteFile');
+ assert.equal(f.app.model.activePath, null);
+ assert.equal(f.node('sourceArea').value, '');
+ f.prompts.push(path);
+ await f.click('newFile');
+ assert.equal(f.app.model.activePath, path);
+ const template = f.app.model.get().text;
+ if (path.endsWith('.tex')) {
+ assert.match(template, /\\documentclass\{article\}/);
+ assert.match(template, /\\begin\{document\}[\s\S]+\\end\{document\}/);
+ } else {
+ assert.match(template, /^%!PS-Adobe-3\.0\n/);
+ assert.match(template, /showpage/);
+ }
+ assert.equal(f.node('sourceArea').value, template);
+ assert.equal(f.context.ProjectModel.deserialize(f.storage.get('visuallatex_workspace_v1')).get().text, template);
+ }
+});
+
+test('file import preserves existing undo history and edits made while awaiting file reads', async () => {
+ const f = await fixture();
+ const original = f.app.model.get().text;
+ await f.input('sourceArea', 'Edit made before importing.');
+ let finishReading;
+ const imported = new TextEncoder().encode('Imported text file.');
+ f.node('importFiles').files = [{name: 'notes.txt', size: imported.length,
+ arrayBuffer: () => new Promise(resolve => { finishReading = resolve; })}];
+ const importing = f.emit(f.node('importFiles'), 'change');
+ const latest = 'Edit made while the imported file is still being read.';
+ await f.input('sourceArea', latest);
+ finishReading(imported.buffer);
+ await importing;
+ assert.equal(f.app.model.activePath, 'notes.txt');
+ assert.equal(f.app.model.get().text, 'Imported text file.');
+ const mainButton = f.node('fileTree').querySelectorAll('button').find(node => node.textContent.includes('main.tex'));
+ await mainButton.click();
+ assert.equal(f.node('sourceArea').value, latest);
+ assert.equal(f.app.model.get().text, latest);
+ assert.equal(f.node('btnUndo').disabled, false);
+ let undoSteps = 0;
+ while (f.app.model.get().text !== original && undoSteps < 2) {
+ await f.click('btnUndo');
+ undoSteps++;
+ }
+ assert.equal(f.app.model.get().text, original);
+ for (let step = 0; step < undoSteps; step++) await f.click('btnRedo');
+ assert.equal(f.app.model.get().text, latest);
+ assert.equal(f.app.model.get('notes.txt').text, 'Imported text file.');
+});
+
+test('ZIP round-trip preserves a user project.json that resembles workspace metadata', async () => {
+ const f = await fixture();
+ const path = '.visual-workspace/project.json';
+ const userJSON = JSON.stringify({format: 'visual-workspace', version: 1,
+ name: 'User JSON name, not the project name', mainPath: 'main.tex', activePath: 'main.tex',
+ userData: {keep: 'This is an ordinary editable project file.'}}, null, 2) + '\n';
+ f.prompts.push(path);
+ await f.click('newFile');
+ await f.input('sourceArea', userJSON);
+ const psButton = f.node('fileTree').querySelectorAll('button').find(node => node.textContent.includes('drawing.ps'));
+ await psButton.click();
+ await f.click('setMainFile');
+ f.node('projectName').value = 'Actual project name';
+ await f.emit(f.node('projectName'), 'change');
+ const userFileButton = f.node('fileTree').querySelectorAll('button').find(node => node.textContent.includes(path));
+ await userFileButton.click();
+ assert.equal(f.app.model.list().length, 4);
+ await f.click('btnExportProject');
+ assert.equal(f.blobs.length, 1, 'a user metadata-like path must not prevent export');
+ const archive = f.blobs[0];
+ await f.click('btnNewProject');
+ f.node('importProject').files = [{name: 'user-metadata.zip', size: archive.size,
+ arrayBuffer: () => archive.arrayBuffer()}];
+ await f.emit(f.node('importProject'), 'change');
+ assert.equal(f.app.model.list().length, 4);
+ assert.equal(f.app.model.get(path)?.text, userJSON);
+ assert.equal(f.app.model.name, 'Actual project name');
+ assert.equal(f.app.model.mainPath, 'drawing.ps');
+ assert.equal(f.app.model.activePath, path);
+ assert.equal(f.node('sourceArea').value, userJSON);
+});
+
+test('a denied localStorage accessor still allows editing and ZIP export without claiming drafts were saved', async () => {
+ const oldDraft = 'The inaccessible original draft must never be overwritten.';
+ const f = await fixture({denyStorage: true, initialStorage: [['visuallatex_workspace_v1', oldDraft]]});
+ assert.ok(f.app);
+ assert.equal(f.app.mode, 'source');
+ assert.equal(f.app.recoveryBlocked, true);
+ assert.match(f.node('statusText').textContent, /项目恢复失败/);
+ const tex = '\\documentclass{article}\n\\begin{document}\nEdited without storage.\n\\end{document}\n';
+ await f.input('sourceArea', tex);
+ const psButton = f.node('fileTree').querySelectorAll('button').find(node => node.textContent.includes('drawing.ps'));
+ await psButton.click();
+ const ps = '%!PS-Adobe-3.0\n(Edited without storage) show\nshowpage\n';
+ await f.input('sourceArea', ps);
+ await f.flushTimers();
+ assert.equal(f.app.model.get('main.tex').text, tex);
+ assert.equal(f.app.model.get('drawing.ps').text, ps);
+ assert.equal(f.app.persist(), false);
+ assert.doesNotMatch(f.node('saveState').textContent, /已保存/);
+ assert.match(f.node('statusText').textContent, /已暂停覆盖/);
+ assert.equal(f.storage.get('visuallatex_workspace_v1'), oldDraft);
+
+ await f.click('btnExportProject');
+ assert.equal(f.blobs.length, 1);
+ assert.equal(f.blobs[0].type, 'application/zip');
+ const files = await f.context.ProjectArchive.readZip(new Uint8Array(await f.blobs[0].arrayBuffer()));
+ const exportedText = path => new TextDecoder().decode(files.find(file => file.path === path).bytes);
+ assert.equal(exportedText('main.tex'), tex);
+ assert.equal(exportedText('drawing.ps'), ps);
+ assert.equal(f.app.recoveryBlocked, true);
+ assert.equal(f.storage.get('visuallatex_workspace_v1'), oldDraft);
+
+ // An explicit new-project action clears the recovery guard, but unavailable
+ // storage must still report failure instead of claiming a successful save.
+ await f.click('btnNewProject');
+ assert.equal(f.app.recoveryBlocked, false);
+ assert.equal(f.app.persist(), false);
+ assert.match(f.node('saveState').textContent, /保存失败/);
+ assert.match(f.node('statusText').textContent, /不允许本机存储/);
+ assert.equal(f.storage.get('visuallatex_workspace_v1'), oldDraft);
+ assert.equal(f.storage.size, 1);
+});
diff --git a/workspace.css b/workspace.css
new file mode 100644
index 0000000..ac1424e
--- /dev/null
+++ b/workspace.css
@@ -0,0 +1,187 @@
+/* Typesetting workbench: paper-white canvas, slate chrome, blue selection. */
+.workspace-app {
+ --bg: #edf1f6;
+ --surface: #ffffff;
+ --surface-soft: #f4f6fa;
+ --border: #dce3ed;
+ --border-strong: #bfcbdb;
+ --text: #1f2d43;
+ --text-secondary: #64738a;
+ --accent: #345f9b;
+ --accent-hover: #274d83;
+ --accent-soft: #e8effa;
+ --ink-dark: #24354f;
+ --success: #345f9b;
+ --success-hover: #274d83;
+ --radius: 5px;
+ --radius-lg: 9px;
+ --editor-font: "Cascadia Code", "Cascadia Mono", Consolas, "Liberation Mono", monospace;
+ font-family: "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
+ background: var(--bg);
+}
+.workspace-app [hidden] { display: none !important; }
+.workspace-app .container { height: 100vh; height: 100dvh; }
+.workspace-app button, .workspace-app input, .workspace-app textarea { font: inherit; }
+.workspace-app button {
+ min-height: 32px; padding: 5px 10px; border: 1px solid var(--border); border-radius: var(--radius);
+ color: var(--text); background: var(--surface); cursor: pointer; white-space: nowrap; font-size: 12px;
+ transition: background-color 120ms ease, border-color 120ms ease;
+}
+.workspace-app button:hover { background: var(--accent-soft); border-color: var(--border-strong); }
+.workspace-app button:disabled { opacity: .44; cursor: not-allowed; }
+.workspace-app button:disabled:hover { background: var(--surface); border-color: var(--border); }
+.workspace-app :is(button, input, textarea, [contenteditable]):focus-visible { outline: 2px solid #3972bc; outline-offset: 2px; }
+.workspace-app button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+.workspace-app button.primary:hover { background: var(--accent-hover); }
+.workspace-app input[type="text"] { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 9px; background: white; color: var(--text); }
+
+.workspace-header { display: flex; align-items: center; gap: 26px; min-height: 66px; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface); }
+.brand-block { display: flex; align-items: center; gap: 15px; flex: 0 0 auto; }
+.brand-block h1 { font-family: Georgia, "Times New Roman", serif; font-size: 22px; letter-spacing: -.6px; font-weight: 600; line-height: 1; }
+.brand-block h1 span { display: block; margin-top: 4px; font: 10px "Segoe UI", sans-serif; letter-spacing: 2.5px; text-transform: uppercase; color: var(--text-secondary); }
+.language-signature { display: inline-flex; gap: 6px; padding: 5px 7px; border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 3px; font: 11px var(--editor-font); color: var(--accent); }
+.language-signature > span { color: var(--border-strong); }
+.project-identity { display: flex; align-items: center; gap: 9px; flex: 1 1 160px; min-width: 110px; max-width: 330px; }
+.project-identity label { color: var(--text-secondary); font-size: 11px; flex-shrink: 0; }
+.project-identity input { width: 100%; font-size: 13px !important; font-weight: 600 !important; border-color: transparent !important; background: var(--surface-soft) !important; }
+.project-identity input:focus { border-color: var(--border-strong) !important; }
+.project-actions { display: flex; gap: 7px; margin-left: auto; }
+.workspace-toolbar.toolbar { flex: 0 0 auto; gap: 9px; padding: 7px 12px; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; border-bottom: 1px solid var(--border); }
+.workspace-toolbar .tool-group { display: flex; flex: 0 0 auto; gap: 4px; }
+.workspace-toolbar.toolbar button { font-size: 12px; min-height: 31px; padding: 4px 9px; font-weight: 400; }
+.workspace-toolbar .format-tools button { border-color: transparent; background: transparent; }
+.workspace-toolbar .format-tools button:hover { background: var(--accent-soft); border-color: var(--border); }
+.workspace-toolbar .separator { height: 21px; margin: 0 1px; flex: 0 0 1px; }
+#btnFiles { display: none; }
+
+.workbench.main-content { display: grid; grid-template-columns: 222px minmax(0, 1fr) 244px; flex: 1; min-height: 0; overflow: hidden; flex-direction: initial; }
+.project-sidebar, .context-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface-soft); overflow: hidden; }
+.project-sidebar { border-right: 1px solid var(--border); }
+.context-sidebar { border-left: 1px solid var(--border); }
+.sidebar-heading { min-height: 47px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 14px; }
+.sidebar-heading h2 { font-size: 12px; font-weight: 650; letter-spacing: .3px; }
+.workspace-app .small-button { min-height: 25px; padding: 2px 6px; border-color: transparent; background: transparent; color: var(--accent); font-size: 11px; }
+.file-import-actions { display: flex; gap: 6px; padding: 0 12px 12px; }
+.file-import-actions button { flex: 1; font-size: 11px; }
+.file-tree { flex: 1; min-height: 80px; overflow: auto; padding: 5px 7px 18px; border-block: 1px solid var(--border); }
+.file-tree button { display: flex; align-items: center; gap: 7px; width: 100%; padding: 8px 9px; min-height: 37px; text-align: left; border: 1px solid transparent; border-radius: 4px; background: transparent; white-space: normal; overflow-wrap: anywhere; font: 12px var(--editor-font); }
+.file-tree button.active, .file-tree button[aria-current="true"], .file-tree button[aria-selected="true"] { background: #e3ebf7; border-color: #cbd9ee; color: #214e8d; box-shadow: inset 3px 0 0 #4977b4; }
+.file-tree button small, .file-tree .file-meta, .file-tree .file-badge { color: var(--text-secondary); font: 10px "Segoe UI", sans-serif; margin-left: auto; }
+.file-tree .file-name { min-width: 0; overflow-wrap: anywhere; }
+.file-actions { display: flex; flex-wrap: wrap; gap: 5px; padding: 10px 10px 4px; }
+.file-actions button { font-size: 10px; padding: 4px 7px; min-height: 28px; background: transparent; }
+.workspace-app button.quiet-danger { color: #9f4242; }
+.sidebar-note { color: var(--text-secondary); font-size: 11px; line-height: 1.7; padding: 8px 13px 14px; }
+
+.document-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); }
+.document-header { display: flex; align-items: center; justify-content: space-between; flex: 0 0 auto; gap: 12px; min-height: 49px; padding: 8px 15px; border-bottom: 1px solid var(--border); }
+.document-location { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.current-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px var(--editor-font); }
+.language-badge { display: inline-flex; padding: 2px 5px; border-radius: 3px; background: #eaf0f8; color: #45678f; font: 10px var(--editor-font); white-space: nowrap; }
+.mode-switch { display: flex; gap: 2px; padding: 2px; background: var(--surface-soft); border: 1px solid var(--border); border-radius: 6px; flex-shrink: 0; }
+.mode-switch button { min-height: 26px; padding: 3px 9px; font-size: 11px; border: 0; background: transparent; color: var(--text-secondary); }
+.mode-switch button.active, .mode-switch button[aria-pressed="true"] { background: white; color: var(--accent); box-shadow: 0 1px 3px #20395818; }
+.source-panel { display: flex; flex: 1; min-height: 0; min-width: 0; overflow: hidden; background: #fff; }
+.line-numbers { flex: 0 0 53px; padding: 19px 11px 40px 5px; margin: 0; background: #f8fafd; border-right: 1px solid #edf1f6; color: #97a4b7; font: 13px/1.75 var(--editor-font); text-align: right; user-select: none; overflow: hidden; tab-size: 4; }
+.workspace-app .source-area { flex: 1; width: 100%; min-width: 0; height: 100%; padding: 19px 22px 40px 16px; border: 0; border-radius: 0; outline: none; resize: none; color: #263d5d; background: #fff; font: 13px/1.75 var(--editor-font); tab-size: 4; white-space: pre; overflow: auto; caret-color: #3269ae; }
+.workspace-app .source-area:focus-visible { outline: none; box-shadow: inset 0 0 0 2px #3972bc25; }
+.source-area::selection { background: #d6e5fb; }
+.visual-panel { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: #edf1f6; }
+.mode-note { flex: 0 0 auto; padding: 8px 16px; border-bottom: 1px solid var(--border); background: #f8fafd; color: var(--text-secondary); font-size: 11px; }
+.workspace-app .editor-area { width: min(100%, 880px); margin: 0 auto; padding: 32px 42px 70px; background: white; min-height: 0; overflow: auto; box-shadow: 0 0 0 1px #dce3ed; }
+.workspace-app .editor-area:focus { background: white; }
+.asset-panel { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 32px; text-align: center; }
+.asset-mark { font: 48px Georgia, serif; color: #8ca3c1; }
+.asset-panel h2 { font-size: 18px; font-weight: 500; }
+.asset-panel p { max-width: 420px; color: var(--text-secondary); font-size: 13px; overflow-wrap: anywhere; }
+.document-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex: 0 0 auto; min-height: 27px; padding: 3px 14px; border-top: 1px solid var(--border); background: #fafbfd; color: var(--text-secondary); font: 10px var(--editor-font); }
+.source-hint { font-family: "Segoe UI", "Microsoft YaHei", sans-serif; margin-left: auto; }
+.workspace-app .encoding-button { min-height: 22px; padding: 2px 5px; font: 10px var(--editor-font); background: transparent; border-color: transparent; color: var(--accent); }
+
+.find-panel { flex: 0 0 auto; padding: 11px 14px 8px; border-bottom: 1px solid var(--border); background: #f2f6fc; box-shadow: inset 3px 0 0 #7797c1; }
+.find-fields { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
+.find-fields > label:first-child { flex: 0 0 28px; font-size: 11px; color: var(--text-secondary); }
+.find-fields > input { flex: 1; min-width: 60px !important; font: 12px var(--editor-font) !important; padding: 6px 8px !important; }
+.find-fields button { font-size: 11px; min-height: 30px; }
+.case-option { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; color: var(--text-secondary); }
+.case-option input { accent-color: var(--accent); }
+.find-footer { display: flex; align-items: center; justify-content: space-between; padding-left: 35px; color: var(--text-secondary); font-size: 11px; }
+.workspace-app .text-button { min-height: 24px; border: none; padding: 2px 6px; background: transparent; color: var(--accent); font-size: 11px; }
+.context-section { display: flex; flex-direction: column; min-height: 0; }
+.outline-section { flex: 0 1 35%; min-height: 150px; border-bottom: 1px solid var(--border); }
+.reference-section { flex: 1; }
+.context-list { min-height: 0; overflow-y: auto; padding: 0 9px 12px; color: var(--text-secondary); font-size: 11px; line-height: 1.7; }
+.context-list button { display: block; width: 100%; text-align: left; padding: 6px 8px; margin-bottom: 2px; border-color: transparent; background: transparent; font-size: 11px; white-space: normal; overflow-wrap: anywhere; }
+.context-list button:hover { background: var(--accent-soft); }
+.context-list small { display: block; color: var(--text-secondary); font: 10px var(--editor-font); margin-top: 2px; }
+.context-list p, .context-list .empty-state { color: var(--text-secondary); font-size: 11px; line-height: 1.7; padding: 5px; }
+.context-list h3 { color: var(--text-secondary); font-size: 10px; font-weight: 500; padding: 10px 5px 4px; }
+.reference-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; padding: 0 12px 12px; }
+.reference-actions button { padding: 5px 6px; font-size: 10px; min-height: 29px; }
+.reference-section .context-list { flex: 1; }
+.workspace-status.status-bar { flex: 0 0 auto; min-height: 28px; padding: 6px 14px; border: 0; background: #24354f; color: #e7edf6; font-size: 10px; gap: 12px; }
+.workspace-status #statusText { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.workspace-status #saveState { white-space: nowrap; color: #b9cbe2; margin-left: auto; }
+.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
+
+.workspace-app .modal-dialog { max-width: 700px; }
+.workspace-app .modal-header { background: var(--ink-dark); }
+.workspace-app .modal-close { color: white; background: transparent; border: 0; }
+.workspace-app .symbol-palette button { font-size: 12px; }
+.workspace-app #formulaInput { font: 14px/1.7 var(--editor-font); }
+
+@media (min-width: 1101px) { #btnOutline { display: none; } }
+@media (max-width: 1100px) {
+ .workbench.main-content { grid-template-columns: 200px minmax(0, 1fr); position: relative; }
+ .context-sidebar { display: none; }
+ .outline-open .context-sidebar { display: flex; position: absolute; z-index: 20; top: 0; right: 0; bottom: 0; width: min(290px, 85vw); box-shadow: -12px 0 28px #1e334724; }
+ .workspace-header { gap: 18px; }
+ .language-signature { display: none; }
+}
+@media (max-width: 700px) {
+ .workspace-header { min-height: auto; padding: 10px 12px; gap: 8px 12px; flex-wrap: wrap; }
+ .brand-block h1 { font-size: 20px; }
+ .brand-block h1 span { font-size: 8px; letter-spacing: 2px; }
+ .project-identity { margin-left: auto; max-width: 210px; flex-basis: 130px; }
+ .project-identity label { display: none; }
+ .project-identity input { min-height: 36px; font-size: 16px !important; }
+ .project-actions { width: 100%; gap: 7px; margin-left: 0; }
+ .project-actions button { flex: 1; min-height: 38px; font-size: 12px; }
+ .workspace-toolbar.toolbar { padding: 6px 8px; gap: 7px; scrollbar-width: none; }
+ .workspace-toolbar.toolbar button { min-height: 40px; padding: 7px 10px; font-size: 12px; }
+ #btnFiles { display: inline-flex; align-items: center; }
+ .workbench.main-content { grid-template-columns: minmax(0, 1fr); }
+ .project-sidebar { display: none; }
+ .files-open .project-sidebar { display: flex; position: absolute; z-index: 21; left: 0; top: 0; bottom: 0; width: min(290px, 85vw); box-shadow: 12px 0 28px #1e334724; }
+ .document-header { min-height: 46px; padding: 7px 10px; gap: 7px; }
+ .document-location { gap: 7px; }
+ .current-path { font-size: 11px; }
+ .language-badge { font-size: 9px; }
+ .mode-switch button { min-height: 30px; font-size: 11px; padding: 4px 8px; }
+ .line-numbers { flex-basis: 39px; padding: 15px 7px 40px 3px; font-size: 16px; line-height: 1.6; }
+ .workspace-app .source-area { padding: 15px 14px 40px 10px; font-size: 16px; line-height: 1.6; }
+ .source-hint { display: none; }
+ .document-footer { min-height: 27px; font-size: 10px; }
+ .find-panel { padding: 9px 9px 6px; }
+ .find-fields { flex-wrap: wrap; gap: 5px; }
+ .find-fields > input { font-size: 16px !important; min-height: 38px; }
+ .find-fields > label:first-child { flex-basis: 25px; }
+ .find-fields button { min-height: 36px; padding: 5px 8px; }
+ .case-option { margin-left: 30px; width: 100%; min-height: 24px; }
+ .find-footer { padding-left: 30px; }
+ .workspace-app .editor-area { padding: 22px 18px 50px; }
+ .mode-note { font-size: 10px; padding: 7px 12px; }
+ .workspace-status.status-bar { min-height: 27px; font-size: 10px; padding: 6px 10px; }
+ .file-tree button, .file-import-actions button, .file-actions button, .reference-actions button, .context-list button { min-height: 40px; }
+ .workspace-app .modal-close { min-height: 44px; }
+}
+@media (max-height: 500px) and (max-width: 700px) {
+ .workspace-header { flex-wrap: nowrap; }
+ .project-identity { display: none; }
+ .project-actions { width: auto; margin-left: auto; }
+ .project-actions button { min-height: 32px; font-size: 11px; }
+ .workspace-toolbar.toolbar button { min-height: 32px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .workspace-app *, .workspace-app *::before, .workspace-app *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
+}
diff --git a/workspace.js b/workspace.js
new file mode 100644
index 0000000..6804466
--- /dev/null
+++ b/workspace.js
@@ -0,0 +1,567 @@
+/* Source-first LaTeX / PostScript workspace. Classic script: runs offline without a build. */
+const WORKSPACE_KEY = 'visuallatex_workspace_v1';
+
+function workspaceTemplate(path) {
+ if (/\.eps$/i.test(path)) return '%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 300 180\n%%EndComments\n/Helvetica findfont 18 scalefont setfont\n30 90 moveto (Hello, EPS) show\n%%EOF\n';
+ if (/\.ps$/i.test(path)) return '%!PS-Adobe-3.0\n%%Title: Untitled\n%%Pages: 1\n%%BoundingBox: 0 0 595 842\n%%EndComments\n%%Page: 1 1\n/Helvetica findfont 24 scalefont setfont\n72 720 moveto (Hello, PostScript) show\nshowpage\n%%EOF\n';
+ if (/\.bib$/i.test(path)) return '@article{example2026,\n author = {Author, A.},\n title = {An example reference},\n journal = {Journal name},\n year = {2026}\n}\n';
+ if (/(^|\/)main\.(tex|latex)$/i.test(path)) return '\\documentclass{article}\n\\usepackage[UTF8]{ctex}\n\\usepackage{amsmath,graphicx}\n\n\\begin{document}\n\\section{开始写作}\\label{sec:start}\n在源码中可以修改导言区、引用和任意命令。\n\n参考文献示例:\\cite{example2026}。\n\n\\bibliographystyle{plain}\n\\bibliography{references}\n\\end{document}\n';
+ return '';
+}
+
+function defaultWorkspace() {
+ return new ProjectModel({name: '我的排版项目', activePath: 'main.tex', mainPath: 'main.tex', files: [
+ {path: 'main.tex', kind: 'text', text: workspaceTemplate('main.tex'), encoding: 'utf-8'},
+ {path: 'references.bib', kind: 'text', text: workspaceTemplate('references.bib'), encoding: 'utf-8'},
+ {path: 'drawing.ps', kind: 'text', text: workspaceTemplate('drawing.ps'), encoding: 'utf-8'}
+ ]});
+}
+
+function workspaceBytes(file) {
+ if (file.kind === 'binary') return Uint8Array.from(atob(file.base64), c => c.charCodeAt(0));
+ if (file.encoding === 'latin1') {
+ if (Array.from(file.text).some(c => c.charCodeAt(0) > 255)) throw new Error('Latin-1 文件含无法编码的字符,请先转换为 UTF-8。');
+ return Uint8Array.from(file.text, c => c.charCodeAt(0));
+ }
+ return new TextEncoder().encode(file.text);
+}
+
+function workspaceFile(path, bytes) {
+ const binaryFormat = /\.(png|jpe?g|gif|webp|bmp|ico|pdf|woff2?|ttf|otf|zip|gz|7z|rar)$/i.test(path);
+ const binaryEPS = bytes.length >= 4 && bytes[0] === 0xc5 && bytes[1] === 0xd0 && bytes[2] === 0xd3 && bytes[3] === 0xc6;
+ if (!binaryFormat && !binaryEPS) {
+ try {
+ const text = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(bytes);
+ // Custom TeX packages and project support files may use arbitrary
+ // extensions. Detect readable UTF-8 without restricting their names.
+ if (!/[\x00-\x08\x0b\x0e-\x1f\x7f]/.test(text)) return {path, kind: 'text', text, encoding: 'utf-8'};
+ } catch (_) { /* PostScript commonly uses an 8-bit text encoding. */ }
+ if (/\.(ps|eps)$/i.test(path) && !bytes.includes(0)) {
+ let text = '';
+ for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192));
+ return {path, kind: 'text', text, encoding: 'latin1'};
+ }
+ }
+ let binary = '';
+ for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
+ return {path, kind: 'binary', base64: btoa(binary)};
+}
+
+// Locate the clicked protected token within its own source unit, not the first
+// identical citation elsewhere in the document. Offsets match textarea newlines.
+function locateWorkspaceToken(editor, target, source) {
+ const normalize = text => text.replace(/\r\n?/g, '\n');
+ const parts = splitTexDocument(source);
+ if (!parts) return -1;
+ const body = normalize(parts.body);
+ const prefix = normalize(parts.preamble + parts.open).length;
+ function inside(node, text) {
+ if (node === target) return 0;
+ let cursor = 0;
+ for (const child of node.childNodes || []) {
+ const rendered = normalize(convertNodeToLatex(child));
+ const index = text.indexOf(rendered, cursor);
+ if (index < 0) continue;
+ if (child === target || (child.contains && child.contains(target))) {
+ const nested = inside(child, rendered);
+ return nested < 0 ? -1 : index + nested;
+ }
+ cursor = index + rendered.length;
+ }
+ return -1;
+ }
+ let cursor = 0;
+ for (const root of editor.childNodes) {
+ const rendered = normalize(convertNodeToLatex(root));
+ const index = body.indexOf(rendered, cursor);
+ if (index < 0) continue;
+ if (root === target || (root.contains && root.contains(target))) {
+ const nested = inside(root, rendered);
+ return nested < 0 ? prefix + index : prefix + index + nested;
+ }
+ cursor = index + rendered.length;
+ }
+ return -1;
+}
+
+class WorkspaceController {
+ constructor({model, ui, core, storage, notify = () => {}}) {
+ this.model = model;
+ this.ui = ui;
+ this.core = core;
+ this.storage = storage;
+ this.notify = notify;
+ this.mode = 'source';
+ this.loading = false;
+ this.recoveryBlocked = false;
+ this.onChange = () => {};
+ }
+ sourceValue(value, original) {
+ const normalize = text => text.replace(/\r\n?/g, '\n');
+ if (normalize(value) === normalize(original)) return original;
+ return original.includes('\r\n') && !/(^|[^\r])\n/.test(original) ? value.replace(/\r?\n/g, '\r\n') : value;
+ }
+ sync(group = false) {
+ if (this.loading) return;
+ const file = this.model.get();
+ if (!file || file.kind !== 'text') return;
+ const text = this.mode === 'visual' ? this.core.exportVisual() : this.sourceValue(this.ui.sourceArea.value, file.text);
+ if (text !== file.text) this.model.update(text, {group});
+ }
+ show() {
+ const file = this.model.get();
+ this.loading = true;
+ this.ui.sourceArea.value = file && file.kind === 'text' ? file.text : '';
+ this.loading = false;
+ this.onChange();
+ }
+ switchFile(path) {
+ this.sync();
+ if (!this.model.get(path)) throw new Error('文件不存在');
+ this.model.activePath = path;
+ this.mode = 'source';
+ this.show();
+ this.persist();
+ }
+ setMode(mode) {
+ if (mode === this.mode) return true;
+ this.sync();
+ const file = this.model.get();
+ if (mode === 'visual') {
+ if (!file || file.kind !== 'text' || !/\.(tex|latex)$/i.test(file.path) || !this.core.canVisual(file.text)) {
+ this.notify('此文件请使用源码编辑。可视化模式需要完整的 LaTeX document 环境。');
+ return false;
+ }
+ this.loading = true;
+ try { this.core.loadVisual(file.text, file.path); }
+ finally { this.loading = false; }
+ }
+ this.mode = mode;
+ this.show();
+ return true;
+ }
+ changed(group = true) {
+ if (this.loading) return;
+ this.sync(group);
+ this.onChange();
+ }
+ history(direction) {
+ this.sync();
+ const changed = this.model[direction]();
+ if (!changed) return;
+ const visual = this.mode === 'visual';
+ this.mode = 'source';
+ this.show();
+ if (visual) this.setMode('visual');
+ this.persist();
+ }
+ persist() {
+ if (this.loading) return true;
+ if (this.recoveryBlocked) { this.notify('旧项目恢复失败,已暂停覆盖。请先导出当前项目,或新建/导入项目。'); return false; }
+ try {
+ this.sync();
+ this.storage.setItem(WORKSPACE_KEY, this.model.serialize());
+ if (this.ui.saveState) this.ui.saveState.textContent = '已保存到本机';
+ return true;
+ } catch (error) {
+ if (this.ui.saveState) this.ui.saveState.textContent = '保存失败,请导出备份';
+ this.notify('保存失败:' + error.message);
+ return false;
+ }
+ }
+ replaceProject(model) {
+ this.model = model;
+ this.mode = 'source';
+ this.recoveryBlocked = false;
+ this.show();
+ this.persist();
+ }
+ exportEntries() {
+ this.sync();
+ return this.model.list().map(file => ({path: file.path, bytes: workspaceBytes(file)}));
+ }
+ exportComment() {
+ // ZIP comments keep project settings out of the user's file namespace.
+ return JSON.stringify({format: 'visual-workspace', version: 1, name: this.model.name,
+ mainPath: this.model.mainPath, activePath: this.model.activePath});
+ }
+}
+
+function initWorkspace() {
+ const $ = id => document.getElementById(id);
+ const ui = {sourceArea: $('sourceArea'), saveState: $('saveState')};
+ const core = {exportVisual: domToLatex, loadVisual: applyImportedTex, canVisual: text => Boolean(splitTexDocument(text))};
+ const notify = message => { $('statusText').textContent = message; };
+ let model;
+ let storage;
+ let blocked = false;
+ try {
+ storage = globalThis.localStorage;
+ const saved = storage.getItem(WORKSPACE_KEY);
+ if (saved !== null) model = ProjectModel.deserialize(saved);
+ else if (localStorage.getItem('visuellatex_document') !== null || localStorage.getItem('visuellatex_draft') !== null) {
+ loadDraft();
+ if (draftRecoveryFailed) throw new Error('单文档草稿无法恢复');
+ const path = importedFileName || 'main.tex';
+ model = new ProjectModel({name: '恢复的项目', activePath: path, mainPath: path, files: [{path, kind: 'text', text: core.exportVisual(), encoding: 'utf-8'}]});
+ } else model = defaultWorkspace();
+ } catch (error) { model = defaultWorkspace(); blocked = true; notify('项目恢复失败,原数据已保留:' + error.message); }
+ // Storage can be unavailable in a restricted browser context. Keep editing
+ // and ZIP downloads usable even when reading the storage accessor throws.
+ storage ||= {setItem() { throw new Error('当前浏览器不允许本机存储,请导出项目备份'); }};
+ const app = new WorkspaceController({model, ui, core, storage, notify});
+ globalThis.visualWorkspace = app;
+ app.recoveryBlocked = blocked;
+ let saveTimer;
+ let panelTimer;
+ const safely = action => async event => {
+ try { await action(event); }
+ catch (error) { notify(error.message); }
+ };
+ const bind = (id, action) => { if ($(id)) $(id).addEventListener('click', safely(action)); };
+ const scheduleSave = () => {
+ if (ui.saveState) ui.saveState.textContent = '待保存';
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => app.persist(), 350);
+ };
+ // Existing visual tools call these hooks after DOM mutations.
+ updatePreview = function() { if (!app.loading) { app.changed(false); scheduleSave(); } };
+ saveDraft = function() { return app.persist(); };
+ initFormulaModal();
+ editorArea.addEventListener('mouseup', saveEditorSelection);
+ editorArea.addEventListener('keyup', saveEditorSelection);
+ editorArea.addEventListener('input', safely(() => { app.changed(true); scheduleSave(); }));
+ ui.sourceArea.addEventListener('input', safely(() => { app.changed(true); scheduleSave(); }));
+ ui.sourceArea.addEventListener('scroll', () => { $('lineNumbers').scrollTop = ui.sourceArea.scrollTop; });
+
+ function position() {
+ const before = ui.sourceArea.value.slice(0, ui.sourceArea.selectionStart);
+ const line = before.split('\n').length;
+ const column = before.length - before.lastIndexOf('\n');
+ $('positionInfo').textContent = '行 ' + line + ',列 ' + column;
+ }
+ ui.sourceArea.addEventListener('click', position);
+ ui.sourceArea.addEventListener('keyup', position);
+
+ function button(text, action, className = '') {
+ const node = document.createElement('button');
+ node.type = 'button'; node.textContent = text; node.className = className;
+ node.addEventListener('click', safely(action));
+ return node;
+ }
+ function jump(path, offset = 0, length = 0) {
+ app.switchFile(path);
+ savedSelection = null;
+ const text = app.model.get().text;
+ const start = text.slice(0, offset).replace(/\r\n?/g, '\n').length;
+ ui.sourceArea.focus(); ui.sourceArea.setSelectionRange(start, start + length);
+ const line = ui.sourceArea.value.slice(0, start).split('\n').length;
+ ui.sourceArea.scrollTop = Math.max(0, (line - 4) * 23);
+ position();
+ document.body.classList.remove('files-open', 'outline-open');
+ $('btnFiles').setAttribute('aria-expanded', 'false');
+ $('btnOutline').setAttribute('aria-expanded', 'false');
+ }
+ function panels() {
+ $('fileTree').replaceChildren();
+ for (const file of app.model.list()) {
+ const prefix = file.path === app.model.mainPath ? '★ ' : '';
+ const item = button(prefix + file.path, () => jump(file.path), 'file-item' + (file.path === app.model.activePath ? ' active' : ''));
+ item.title = file.kind === 'binary' ? '二进制资源,导出时保留原始字节' : ProjectModel.language(file.path);
+ $('fileTree').appendChild(item);
+ }
+ $('outlineList').replaceChildren();
+ const file = app.model.get();
+ if (file && file.kind === 'text') {
+ const pattern = ProjectModel.language(file.path) === 'postscript' ? /^(?:%%(?:Page|Title):\s*(.+)|\/([\w.-]+)\s*\{)/gm : /\\(?:section|subsection|subsubsection)\*?(?:\[[^\]]*\])?\{([^}\n]+)\}/g;
+ for (const match of file.text.matchAll(pattern)) $('outlineList').appendChild(button(match[1] || match[2], () => jump(file.path, match.index), 'outline-item'));
+ }
+ if (!$('outlineList').childNodes.length) $('outlineList').textContent = '当前文件没有章节或 PostScript 页面标记。';
+ $('referenceList').replaceChildren();
+ const refs = app.model.references();
+ for (const item of [...refs.labels, ...refs.bibliography, ...refs.citations, ...(refs.references || [])]) {
+ $('referenceList').appendChild(button(item.key + ' · ' + item.path, () => jump(item.path, item.offset), 'reference-item'));
+ }
+ if (!$('referenceList').childNodes.length) $('referenceList').textContent = '添加 \\label 或 BibTeX 条目后,可在这里定位。';
+ }
+ function render() {
+ const file = app.model.get();
+ const text = file && file.kind === 'text';
+ const language = file ? ProjectModel.language(file.path) : 'text';
+ $('currentPath').textContent = file ? file.path : '选择或新建文件';
+ $('languageBadge').textContent = file && file.kind === 'binary' ? '资源' : {latex: 'LaTeX', bibtex: 'BibTeX', postscript: 'PostScript', text: '文本'}[language];
+ $('projectName').value = app.model.name;
+ $('sourcePanel').hidden = !text || app.mode !== 'source';
+ $('visualPanel').hidden = !text || app.mode !== 'visual';
+ $('assetPanel').hidden = Boolean(text);
+ $('assetMessage').textContent = file ? '二进制或非文本资源。项目导出会保留原始字节,可下载当前文件。' : '从左侧导入文件,或新建 .tex、.bib、.ps、.eps 文件。';
+ $('modeSource').classList.toggle('active', app.mode === 'source');
+ $('modeVisual').classList.toggle('active', app.mode === 'visual');
+ $('modeSource').setAttribute('aria-pressed', String(app.mode === 'source'));
+ $('modeVisual').setAttribute('aria-pressed', String(app.mode === 'visual'));
+ $('modeVisual').disabled = !text || !/\.(tex|latex)$/i.test(file.path);
+ $('modeVisual').title = '仅完整 LaTeX 文档支持可视化;其他文件直接编辑源码';
+ $('btnUndo').disabled = !text || !app.model.canUndo();
+ $('btnRedo').disabled = !text || !app.model.canRedo();
+ for (const id of ['btnH1','btnH2','btnH3','btnBold','btnItalic','btnCenter','btnUl','btnOl','btnInlineFormula','btnBlockFormula','btnImage','btnInsertCitation','btnInsertReference','btnInsertLabel']) $(id).disabled = !text || language !== 'latex';
+ for (const id of ['btnCopy','btnFind']) $(id).disabled = !text;
+ for (const id of ['renameFile','deleteFile','btnDownload']) $(id).disabled = !file;
+ $('setMainFile').disabled = !text;
+ if ($('btnEncoding')) {
+ $('btnEncoding').disabled = !text;
+ $('btnEncoding').textContent = text ? file.encoding.toUpperCase() : '二进制';
+ }
+ const count = (ui.sourceArea.value.match(/\n/g) || []).length + 1;
+ $('lineNumbers').textContent = Array.from({length: count}, (_, i) => i + 1).join('\n');
+ position();
+ clearTimeout(panelTimer); panelTimer = setTimeout(panels, 180);
+ }
+ app.onChange = render;
+ function sourceEdit(replacement, start = ui.sourceArea.selectionStart, end = ui.sourceArea.selectionEnd) {
+ if (!app.setMode('source')) return;
+ const file = app.model.get();
+ if (!file || file.kind !== 'text') throw new Error('请先选择文本文件');
+ const value = ui.sourceArea.value;
+ ui.sourceArea.value = value.slice(0, start) + replacement + value.slice(end);
+ ui.sourceArea.setSelectionRange(start + replacement.length, start + replacement.length);
+ app.changed(false); app.persist(); ui.sourceArea.focus(); position();
+ }
+ function wrap(before, after) {
+ app.setMode('source');
+ const start = ui.sourceArea.selectionStart;
+ const selected = ui.sourceArea.value.slice(start, ui.sourceArea.selectionEnd);
+ sourceEdit(before + selected + after);
+ if (!selected) ui.sourceArea.setSelectionRange(start + before.length, start + before.length);
+ }
+ bind('modeSource', () => app.setMode('source'));
+ bind('modeVisual', () => app.setMode('visual'));
+ bind('btnUndo', () => app.history('undo'));
+ bind('btnRedo', () => app.history('redo'));
+ bind('btnFiles', () => $('btnFiles').setAttribute('aria-expanded', String(document.body.classList.toggle('files-open'))));
+ bind('btnOutline', () => $('btnOutline').setAttribute('aria-expanded', String(document.body.classList.toggle('outline-open'))));
+ bind('btnEncoding', () => {
+ const file = app.model.get(); if (!file || file.kind !== 'text') return;
+ const encoding = prompt('文件编码:utf-8 或 latin1(转换会影响导出的字节编码)', file.encoding);
+ if (!encoding || encoding === file.encoding) return;
+ if (file.encoding !== 'latin1') app.sync();
+ app.model.setEncoding(file.path, encoding.trim().toLowerCase());
+ app.changed(false); app.persist();
+ });
+ $('projectName').addEventListener('change', safely(() => { app.model.name = $('projectName').value.trim() || '未命名项目'; app.persist(); }));
+
+ for (const [id, visual, before, after] of [
+ ['btnH1', () => insertHeading(1), '\\section{', '}'], ['btnH2', () => insertHeading(2), '\\subsection{', '}'],
+ ['btnH3', () => insertHeading(3), '\\subsubsection{', '}'], ['btnBold', toggleBold, '\\textbf{', '}'],
+ ['btnItalic', toggleItalic, '\\textit{', '}'], ['btnCenter', toggleCenter, '\\begin{center}\n', '\n\\end{center}'],
+ ['btnUl', insertUnorderedList, '\\begin{itemize}\n \\item ', '\n\\end{itemize}'],
+ ['btnOl', insertOrderedList, '\\begin{enumerate}\n \\item ', '\n\\end{enumerate}'],
+ ['btnInlineFormula', insertInlineFormula, '$', '$'], ['btnBlockFormula', insertBlockFormula, '\\[\n', '\n\\]']
+ ]) bind(id, () => { if (app.mode === 'visual') visual(); else wrap(before, after); });
+ bind('btnImage', () => {
+ if (app.mode === 'visual') { insertImage(); return; }
+ const path = prompt('图片路径(相对于主文件)', 'figures/example.eps');
+ if (path) sourceEdit('\\includegraphics[width=0.6\\textwidth]{' + path + '}');
+ });
+ editorArea.addEventListener('dblclick', safely(event => {
+ const token = event.target.closest && event.target.closest('.tex-cmd');
+ if (!token) return;
+ app.sync();
+ const source = convertNodeToLatex(token).replace(/\r\n?/g, '\n');
+ const offset = locateWorkspaceToken(editorArea, token, app.model.get().text);
+ app.setMode('source');
+ ui.sourceArea.focus();
+ if (offset >= 0) ui.sourceArea.setSelectionRange(offset, offset + source.length);
+ position();
+ notify(offset >= 0 ? '已定位到完整源码,可直接修改该命令。' : '已打开完整源码,可使用查找定位该命令。');
+ }));
+
+ function insertReference(kind) {
+ app.setMode('source');
+ const refs = app.model.references();
+ const first = kind === 'cite' ? refs.bibliography[0] : refs.labels[0];
+ const key = prompt(kind === 'cite' ? '文献键,可用逗号分隔多个键' : '标签名称', first ? first.key : kind === 'label' ? 'sec:new' : '');
+ if (key) sourceEdit('\\' + kind + '{' + key + '}');
+ }
+ bind('btnInsertCitation', () => insertReference('cite'));
+ bind('btnInsertReference', () => insertReference('ref'));
+ bind('btnInsertLabel', () => insertReference('label'));
+ bind('btnAddBibEntry', () => {
+ app.sync();
+ const key = prompt('新文献的 BibTeX 键', 'reference2026');
+ if (!key) return;
+ if (!/^[^\s{},=]+$/.test(key)) throw new Error('文献键不能包含空白、花括号、逗号或等号');
+ if (app.model.references().bibliography.some(item => item.key === key)) throw new Error('该文献键已存在');
+ let file = app.model.get();
+ if (!file || !/\.bib$/i.test(file.path)) file = app.model.list().find(item => /\.bib$/i.test(item.path));
+ if (!file) file = app.model.add({path: 'references.bib', kind: 'text', text: '', encoding: 'utf-8'});
+ app.switchFile(file.path);
+ const end = ui.sourceArea.value.length;
+ sourceEdit('\n@article{' + key + ',\n author = {},\n title = {},\n journal = {},\n year = {}\n}\n', end, end);
+ });
+
+ bind('newFile', () => {
+ app.sync();
+ const path = prompt('文件路径,例如 sections/method.tex、references.bib 或 drawing.ps', 'untitled.tex');
+ if (!path) return;
+ const file = app.model.add({path, kind: 'text', text: workspaceTemplate(path), encoding: 'utf-8'});
+ // add() selects the first file in an empty project. Do not sync the old
+ // empty editor into that newly created template.
+ app.model.activePath = file.path; app.mode = 'source'; app.show(); app.persist();
+ });
+ bind('renameFile', () => {
+ app.sync();
+ const file = app.model.get(); if (!file) return;
+ const path = prompt('新的文件路径(已有源码中的路径引用不会自动更名)', file.path);
+ if (path && path !== file.path) { const renamed = app.model.rename(file.path, path); app.switchFile(renamed.path); }
+ });
+ bind('deleteFile', () => {
+ const file = app.model.get(); if (!file || !confirm('删除项目文件 ' + file.path + '?请先导出需要保留的内容。')) return;
+ app.model.remove(file.path); app.mode = 'source'; app.show(); app.persist();
+ });
+ bind('setMainFile', () => { app.sync(); app.model.mainPath = app.model.activePath; app.persist(); panels(); notify('已设为项目主文件'); });
+ bind('btnNewProject', () => { if (confirm('新建项目会替换本机当前草稿,请先导出备份。继续?')) app.replaceProject(defaultWorkspace()); });
+
+ function download(bytes, name, type = 'application/octet-stream') {
+ const url = URL.createObjectURL(new Blob([bytes], {type}));
+ const a = document.createElement('a'); a.href = url; a.download = name;
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+ bind('btnDownload', () => { app.sync(); const file = app.model.get(); if (file) download(workspaceBytes(file), file.path.split('/').pop()); });
+ bind('btnExportProject', () => {
+ const bytes = ProjectArchive.writeZip(app.exportEntries(), {comment: app.exportComment()});
+ download(bytes, (app.model.name || 'project').replace(/[\\/:*?"<>|]/g, '_') + '.zip', 'application/zip');
+ notify('项目 ZIP 已导出,包含所有源码和资源文件');
+ });
+ bind('btnCopy', async () => {
+ app.sync(); const file = app.model.get(); if (!file || file.kind !== 'text') return;
+ if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(file.text);
+ else {
+ app.setMode('source'); ui.sourceArea.select();
+ if (!document.execCommand('copy')) throw new Error('浏览器禁止自动复制,请按 Ctrl+C');
+ }
+ notify('当前文件源码已复制');
+ });
+
+ function uniquePath(path, names) {
+ let candidate = path; let count = 2;
+ const dot = path.lastIndexOf('.'); const slash = path.lastIndexOf('/');
+ const stem = dot > slash ? path.slice(0, dot) : path;
+ const ext = dot > slash ? path.slice(dot) : '';
+ while (names.has(candidate.normalize('NFC').toLowerCase())) candidate = stem + '_' + count++ + ext;
+ names.add(candidate.normalize('NFC').toLowerCase());
+ return candidate;
+ }
+ bind('btnImportFiles', () => { $('importFiles').value = ''; $('importFiles').click(); });
+ bind('btnImportFolder', () => { $('importFolder').value = ''; $('importFolder').click(); });
+ bind('btnImportProject', () => { $('importProject').value = ''; $('importProject').click(); });
+ async function readFiles(files, folder) {
+ if (files.length > ProjectModel.MAX_FILES || files.reduce((sum, file) => sum + file.size, 0) > ProjectModel.MAX_TOTAL_BYTES) throw new Error('项目最多 500 个文件、50 MB');
+ const entries = [];
+ for (const file of files) {
+ const path = folder ? file.webkitRelativePath.split('/').slice(1).join('/') : file.name;
+ entries.push(workspaceFile(path, new Uint8Array(await file.arrayBuffer())));
+ }
+ return entries;
+ }
+ $('importFiles').addEventListener('change', safely(async event => {
+ app.sync();
+ const entries = await readFiles(Array.from(event.target.files), false);
+ // Reading files is asynchronous: retain any edits made while waiting.
+ // addMany validates the full batch atomically and preserves undo history.
+ app.sync();
+ const names = new Set(app.model.list().map(file => file.path.normalize('NFC').toLowerCase()));
+ const added = app.model.addMany(entries.map(file => ({...file, path: uniquePath(file.path, names)})));
+ if (added.length) {
+ app.model.activePath = added.at(-1).path; app.mode = 'source'; app.show(); app.persist();
+ }
+ notify('已导入 ' + entries.length + ' 个文件;重名文件已自动另存');
+ }));
+ $('importFolder').addEventListener('change', safely(async event => {
+ const files = Array.from(event.target.files); if (!files.length) return;
+ const entries = await readFiles(files, true);
+ const main = entries.find(file => file.kind === 'text' && file.path === 'main.tex') || entries.find(file => file.kind === 'text' && /\.(tex|ps|eps)$/i.test(file.path)) || entries.find(file => file.kind === 'text');
+ const first = main || entries[0];
+ const candidate = new ProjectModel({name: files[0].webkitRelativePath.split('/')[0], files: entries, activePath: first.path, mainPath: main ? main.path : null});
+ if (confirm('用这个文件夹替换当前项目?请确认已导出需要保留的内容。')) app.replaceProject(candidate);
+ }));
+ $('importProject').addEventListener('change', safely(async event => {
+ const file = event.target.files[0]; if (!file) return;
+ if (file.size > ProjectArchive.MAX_ARCHIVE_BYTES) throw new Error('项目包超过 100 MB(展开后的文件总计最多 50 MB)');
+ let candidate;
+ if (/\.(json|vlproj)$/i.test(file.name)) candidate = ProjectModel.deserialize(await file.text());
+ else {
+ const archive = await ProjectArchive.readZip(new Uint8Array(await file.arrayBuffer()), {includeComment: true});
+ let metadata = null;
+ try {
+ const value = JSON.parse(archive.comment);
+ if (value && value.format === 'visual-workspace' && value.version === 1 &&
+ typeof value.name === 'string' && value.name.trim() && value.name.length <= 200) metadata = value;
+ } catch (_) { /* ZIP comments from other tools are not project metadata. */ }
+ const files = archive.files.map(entry => workspaceFile(entry.path, entry.bytes));
+ const main = files.find(item => item.kind === 'text' && item.path === 'main.tex') || files.find(item => item.kind === 'text' && /\.(tex|ps|eps)$/i.test(item.path)) || files.find(item => item.kind === 'text');
+ const first = main || files[0];
+ candidate = new ProjectModel({name: metadata ? metadata.name : file.name.replace(/\.zip$/i, ''), files,
+ activePath: metadata && files.some(item => item.path === metadata.activePath) ? metadata.activePath : first && first.path,
+ mainPath: metadata && files.some(item => item.kind === 'text' && item.path === metadata.mainPath) ? metadata.mainPath : main ? main.path : null});
+ }
+ if (confirm('用导入的项目替换当前项目?请确认已导出需要保留的内容。')) app.replaceProject(candidate);
+ }));
+
+ function literalFindPattern(query) {
+ return new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $('findCase').checked ? 'g' : 'gi');
+ }
+ function findNext() {
+ app.setMode('source');
+ const needle = $('findText').value; if (!needle) return -1;
+ const value = ui.sourceArea.value;
+ const pattern = literalFindPattern(needle);
+ pattern.lastIndex = ui.sourceArea.selectionEnd;
+ let match = pattern.exec(value);
+ if (!match) { pattern.lastIndex = 0; match = pattern.exec(value); }
+ const index = match ? match.index : -1;
+ $('findStatus').textContent = index < 0 ? '未找到' : '已找到匹配';
+ if (match) { ui.sourceArea.focus(); ui.sourceArea.setSelectionRange(index, index + match[0].length); position(); }
+ return index;
+ }
+ bind('btnFind', () => { app.setMode('source'); $('findPanel').hidden = false; $('findText').focus(); });
+ bind('btnCloseFind', () => { $('findPanel').hidden = true; ui.sourceArea.focus(); });
+ bind('btnFindNext', findNext);
+ bind('btnReplace', () => {
+ app.setMode('source');
+ const query = $('findText').value; if (!query) return;
+ const selected = ui.sourceArea.value.slice(ui.sourceArea.selectionStart, ui.sourceArea.selectionEnd);
+ const match = literalFindPattern(query).exec(selected);
+ const matches = match && match.index === 0 && match[0].length === selected.length;
+ if (matches || findNext() >= 0) sourceEdit($('replaceText').value);
+ });
+ bind('btnReplaceAll', () => {
+ app.setMode('source');
+ const query = $('findText').value; if (!query) return;
+ const value = ui.sourceArea.value;
+ const replacement = $('replaceText').value;
+ let count = 0;
+ // Match the original source so Unicode case folding cannot shift offsets.
+ // A callback keeps replacement tokens such as $& literal.
+ const result = value.replace(literalFindPattern(query), () => { count++; return replacement; });
+ if (count) sourceEdit(result, 0, value.length);
+ $('findStatus').textContent = '已替换 ' + count + ' 处';
+ });
+ document.addEventListener('keydown', safely(event => {
+ if (!(event.ctrlKey || event.metaKey)) return;
+ if (event.key.toLowerCase() === 's') { event.preventDefault(); app.persist(); return; }
+ const editing = event.target === ui.sourceArea || editorArea.contains(event.target);
+ if (!editing) return;
+ if (event.key.toLowerCase() === 'f') { event.preventDefault(); $('btnFind').click(); }
+ if (event.key.toLowerCase() === 'z') { event.preventDefault(); app.history(event.shiftKey ? 'redo' : 'undo'); }
+ if (event.key.toLowerCase() === 'y') { event.preventDefault(); app.history('redo'); }
+ if (event.key.toLowerCase() === 'a' && app.mode === 'source') { /* native selection */ }
+ }));
+ ui.sourceArea.addEventListener('keydown', safely(event => {
+ if (event.key === 'Tab') { event.preventDefault(); sourceEdit(' '); }
+ }));
+ window.addEventListener('pagehide', () => { clearTimeout(saveTimer); app.persist(); });
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') app.persist(); });
+ app.show(); panels();
+ if (!blocked) notify('源码模式:导言区、引用、LaTeX 和 PostScript 均可直接编辑');
+}
From 4a5dc84ff0b657ba176b383fa0ebfd6d4257dd97 Mon Sep 17 00:00:00 2001
From: nisconder <2129048448@qq.com>
Date: Wed, 28 Oct 2026 13:14:39 +0800
Subject: [PATCH 2/7] Add local LaTeX compilation, PostScript rendering and PDF
preview
---
.github/workflows/build.yml | 13 +-
.github/workflows/ci.yml | 8 +-
.github/workflows/deploy-pages.yml | 11 +-
.gitignore | 1 +
README.en.md | 61 +++++-
README.md | 62 +++++-
compiler.mjs | 289 ++++++++++++++++++++++++++
index.html | 32 +++
preview.css | 45 ++++
preview.js | 306 +++++++++++++++++++++++++++
server.mjs | 197 ++++++++++++++++++
setup-windows.ps1 | 94 +++++++++
start.ps1 | 21 ++
tests/compiler.test.mjs | 257 +++++++++++++++++++++++
tests/preview.test.mjs | 319 +++++++++++++++++++++++++++++
tests/server.test.mjs | 124 +++++++++++
tests/workspace-markup.test.mjs | 4 +-
workspace.js | 1 +
18 files changed, 1821 insertions(+), 24 deletions(-)
create mode 100644 compiler.mjs
create mode 100644 preview.css
create mode 100644 preview.js
create mode 100644 server.mjs
create mode 100644 setup-windows.ps1
create mode 100644 start.ps1
create mode 100644 tests/compiler.test.mjs
create mode 100644 tests/preview.test.mjs
create mode 100644 tests/server.test.mjs
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4587162..9d6c133 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,22 +18,27 @@ jobs:
node-version: '22'
- name: JS 语法检查
- run: node --check main.js
+ shell: pwsh
+ run: |
+ foreach ($file in @('main.js', 'workspace.js', 'project-model.js', 'project-archive.js', 'preview.js', 'compiler.mjs', 'server.mjs')) {
+ node --check $file
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
- name: 冒烟检查
run: node tests/smoke.mjs
- - name: 导入、导出与编辑回归测试
+ - name: 编辑、编译服务与预览回归测试
run: node --test
- # 纯静态项目无运行时依赖,无需安装
+ # 工具链由用户自行安装,不将第三方引擎二进制打入源码包。
- name: 打包项目为 zip
shell: pwsh
run: |
$tag = $env:GITHUB_REF_NAME
$name = "VisualLaTeX-$tag"
New-Item -ItemType Directory -Path $name -Force | Out-Null
- Copy-Item index.html, style.css, workspace.css, main.js, workspace.js, project-model.js, project-archive.js, LICENSE, README.md, README.en.md -Destination $name
+ Copy-Item index.html, style.css, workspace.css, preview.css, main.js, workspace.js, project-model.js, project-archive.js, preview.js, compiler.mjs, server.mjs, start.ps1, setup-windows.ps1, LICENSE, README.md, README.en.md -Destination $name
Copy-Item screenshots -Destination $name -Recurse
Copy-Item tests -Destination $name -Recurse
Compress-Archive -Path $name -DestinationPath "$name.zip"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9a10ed5..45776f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,13 +15,15 @@ jobs:
with:
node-version: '22'
- # JS 语法检查(等效于 compileall)
- name: JS 语法检查
- run: node --check main.js
+ run: |
+ for file in main.js workspace.js project-model.js project-archive.js preview.js compiler.mjs server.mjs; do
+ node --check "$file"
+ done
# 无头冒烟检查:资源完整、标签配对、DOM id 一致性
- name: 冒烟检查
run: node tests/smoke.mjs
- - name: 导入、导出与编辑回归测试
+ - name: 编辑、编译服务与预览回归测试
run: node --test
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index fd61209..91811ee 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -29,17 +29,24 @@ jobs:
- name: 验证待部署的同一提交
run: |
- node --check main.js
+ for file in main.js workspace.js project-model.js project-archive.js preview.js compiler.mjs server.mjs; do
+ node --check "$file"
+ done
node tests/smoke.mjs
node --test
- name: 配置 Pages
uses: actions/configure-pages@v5
+ - name: 准备静态编辑器
+ run: |
+ mkdir site
+ cp index.html style.css workspace.css preview.css main.js workspace.js project-model.js project-archive.js preview.js LICENSE site/
+
- name: 上传站点产物
uses: actions/upload-pages-artifact@v3
with:
- path: . # 纯静态项目,直接上传仓库根目录
+ path: site # Pages 仅提供编辑;编译需要本机 Node 服务和工具链。
deploy:
needs: build
diff --git a/.gitignore b/.gitignore
index dbd64df..be00fc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
node_modules/
test-results/
playwright-report/
+.tools/
diff --git a/README.en.md b/README.en.md
index 24943a2..a97256c 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1,17 +1,52 @@
# VisualLaTeX Studio
-**An offline project editor for LaTeX and PostScript.**
+**A LaTeX / PostScript project editor with local compilation, PDF preview and export.**
-This workspace edition extends [Escap1ng/VisualLaTeX](https://github.com/Escap1ng/VisualLaTeX) with full source editing, multiple files, references, bibliography entries and ZIP projects. It has no runtime dependencies or build step.
+This workspace edition extends [Escap1ng/VisualLaTeX](https://github.com/Escap1ng/VisualLaTeX) with full source editing, multiple files, references, bibliography entries, ZIP projects and real compilation. No npm packages or build step are required. Compilation uses Node.js 22+ and a local typesetting toolchain.
## Start
-Unpack the source archive and open `index.html`. The sample project contains `main.tex`, `references.bib` and `drawing.ps`.
+The sample project contains `main.tex`, `references.bib` and `drawing.ps`. Opening `index.html` directly supports editing; use the local service for PDF preview and export.
+
+### Windows
+
+Install [Node.js 22 or newer](https://nodejs.org/en/download), unpack this project and run PowerShell in its directory:
+
+```powershell
+.\setup-windows.ps1
+.\start.ps1
+```
+
+If PowerShell blocks scripts under its default policy, use `powershell -NoProfile -ExecutionPolicy Bypass -File .\setup-windows.ps1` for that invocation, and likewise for `start.ps1`. No system policy change is required.
+
+Setup downloads pinned official Tectonic 0.17.0, Ghostscript 10.08.0 and extraction tools, verifies their published SHA-256 digests, and unpacks them under `.tools`. It never runs the installers, changes the registry, or overwrites project files. Existing tools are retained on subsequent runs; failed downloads and staging files remain available for inspection. Third-party engine binaries are not distributed in this source archive; their licenses remain in their download packages.
+
+Open the address printed by the terminal, normally `http://127.0.0.1:4173`. Keep the terminal running and press Ctrl+C to stop. Use `.\start.ps1 -Port 4174` to choose another port.
+
+### macOS, Linux or an existing toolchain
+
+Install [Tectonic](https://tectonic-typesetting.github.io/book/latest/installation/index.html) or XeLaTeX and [Ghostscript](https://ghostscript.com/releases/gsdnld.html), make their executables available on PATH, then run:
+
+```bash
+node server.mjs
+```
+
+Executable paths can also be supplied using `VISUALLATEX_TECTONIC`, `VISUALLATEX_XELATEX`, and `VISUALLATEX_GHOSTSCRIPT`. XeLaTeX bibliography tools accept `VISUALLATEX_BIBTEX` and `VISUALLATEX_BIBER`. Set XeLaTeX explicitly to choose an existing TeX distribution; do not configure both LaTeX engines at once.
+
+Tectonic downloads missing TeX support files on first use and caches them for later compilation. Source is compiled locally and is not uploaded to a third-party service. The Windows launcher uses `.tools/cache`; `TECTONIC_CACHE_DIR` overrides this location. Existing TeX distributions manage their own packages and fonts.
+
+### Editing and PDF output
- Add individual files, open a project folder, or import a ZIP project.
- Edit any text file in source mode, including preambles, macros, references and incomplete source.
- Complete LaTeX documents can switch to the visual editor. Fragments, BibTeX, PostScript and EPS text remain directly editable source.
- Download one file or export the entire project as ZIP, including binary resources and changes in inactive files.
+- Select the complete LaTeX main document or a PS/EPS file, then compile/render with the button or Ctrl/Cmd+Enter.
+- Inspect the generated PDF and export it. Running tasks can be cancelled; errors retain engine logs for diagnosis.
+
+Source changes mark an older PDF as stale. Export recompiles changed content before downloading, while failed compilation keeps the previous preview visibly marked and leaves the source editable. If the browser cannot display embedded PDFs, download the file and open it in a local reader.
+
+Direct `file://` pages and GitHub Pages remain editing-only. Browser drafts are isolated by origin: export a ZIP from the old offline page before switching to the local service, then import it there.
Opening a folder or project replaces the current workspace after confirmation. Adding individual files preserves existing files and assigns new names to duplicates. Renaming a path does not rewrite source references automatically.
@@ -28,10 +63,13 @@ Opening a folder or project replaces the current workspace after confirmation. A
| Resources | Preserve original binary bytes, including binary EPS |
| Archives | Read stored/deflated ZIP and export standard ZIP without packages |
| Drafts | Atomic local project snapshots and legacy single-file migration |
+| LaTeX compilation | Tectonic or XeLaTeX with project files, resources, references and bibliography processing |
+| PostScript rendering | Ghostscript converts PS/EPS to PDF; EPS uses BoundingBox cropping |
+| PDF output | Target selection, preview, cancellation, logs, stale-result tracking and current-source export |
Double-click a protected visual command to locate its editable source; double-click a formula to edit it. Unsupported visual structures remain editable raw TeX. New `main.tex` files have a document template; other TeX files start empty for use as fragments.
-Shortcuts: Ctrl/Cmd+S saves locally, Ctrl/Cmd+F opens find/replace, Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z or Ctrl+Y redoes, Tab inserts two spaces. The encoding button explicitly switches UTF-8/Latin-1; unencodable characters are rejected rather than replaced.
+Shortcuts: Ctrl/Cmd+S saves locally, Ctrl/Cmd+F opens find/replace, Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z or Ctrl+Y redoes, Ctrl/Cmd+Enter compiles/renders, and Tab inserts two spaces. The encoding button explicitly switches UTF-8/Latin-1; unencodable characters are rejected rather than replaced.
## Limits
@@ -39,7 +77,11 @@ Projects allow 500 files and 50 MB of expanded content; ZIP input allows 100 MB.
Project name, main file and active file are stored in the ZIP comment without reserving or replacing any user filename. Third-party repacking may remove these settings; project source and resources can still be imported.
-The editor **does not execute or render TeX/PostScript and does not compile PDF**. Use an existing TeX/Ghostscript toolchain with the exported files. It does not expand TeX macros or provide full syntax diagnostics. Source mode remains available when visual editing is unsupported.
+Compilation is explicit and has a default 180-second timeout; initial support-file downloads may take longer. Tectonic performs TeX/BibTeX passes automatically. BibLaTeX/Biber projects should use XeLaTeX with Biber installed. CJK and custom fonts must be available to the toolchain. Packages requiring shell escape, such as some minted workflows, are unsupported. Full typesetting and diagnostics come from the engine; the visual editor is not a complete TeX parser.
+
+Font validation limitation: with Tectonic 0.17.0 and the default ctex/Fandol fonts, the tested Chinese PDF looked correct but copied/extracted text had incorrect character mappings. On Windows with the corresponding system fonts installed, explicitly using `\usepackage[UTF8,fontset=windows]{ctex}` restored text copying in the tested sample. The application never changes project font settings automatically; other platforms and font combinations need separate verification.
+
+The service binds only to loopback and validates Host, Origin and a session token. Temporary project directories are removed after jobs finish. Tectonic uses `--untrusted`, XeLaTeX disables shell escape, and Ghostscript uses `SAFER`. These options **are not an operating-system sandbox**: compile trusted local projects, and do not expose this service as a public compilation server.
## Development
@@ -48,13 +90,18 @@ Node.js 22+, no npm installation:
```bash
node --check main.js
node --check workspace.js
+node --check preview.js
+node --check compiler.mjs
+node --check server.mjs
node tests/smoke.mjs
node --test
```
-Tests exercise actual functions and UI event handlers with a minimal DOM model, not a browser engine or TeX/PS compiler. CI checks `main`/`dev`; publishing and deployment wait for checks.
+Tests exercise actual functions and UI event handlers, compiler process orchestration, loopback service boundaries, cancellation and stale results. Regular tests use simulated engines and do not need the toolchain. The minimal DOM model does not verify browser layout or actual TeX/PS typesetting. CI checks `main`/`dev`; publishing and deployment wait for checks. GitHub Pages serves only the static editor.
+
+Actual engine validation in this iteration used Tectonic and Ghostscript; the XeLaTeX path has not yet been verified against a real distribution.
-`workspace.js` coordinates the UI, `project-model.js` owns canonical source/history/indexes, and `project-archive.js` handles ZIP. The original `main.js` remains the visual LaTeX conversion core.
+`workspace.js` coordinates editing, `project-model.js` owns canonical source/history/indexes, and `project-archive.js` handles ZIP. `preview.js` manages PDF state and export; `compiler.mjs` and `server.mjs` run the local compilation service. `setup-windows.ps1` and `start.ps1` prepare portable tools and start the service. The original `main.js` remains the visual LaTeX conversion core.
## License
diff --git a/README.md b/README.md
index abaae27..64141f4 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,53 @@
# VisualLaTeX Studio
-**LaTeX 与 PostScript 的离线项目编辑器。**
+**支持本地编译、PDF 预览与导出的 LaTeX / PostScript 项目编辑器。**
-这是基于 [Escap1ng/VisualLaTeX](https://github.com/Escap1ng/VisualLaTeX) 的独立工作区版本,在原有无损导入修复之上,加入完整源码、多文件项目、参考文献与 ZIP 管理。运行时零依赖,无需安装或联网。
+这是基于 [Escap1ng/VisualLaTeX](https://github.com/Escap1ng/VisualLaTeX) 的独立工作区版本,提供完整源码、多文件项目、参考文献、ZIP 管理及真实编译。编辑器与本地服务不依赖 npm 包;编译需要 Node.js 22+ 和相应工具链。
**所有文本文件都能直接编辑。** 导言区、宏定义、`\cite`、`\ref`、`\label`、BibTeX 条目以及 PostScript 程序不再受可视化解析能力限制。未完成的源码也可以保存和导出。
## 开始使用
-下载并解压源码包,双击 `index.html`。默认项目包含 `main.tex`、`references.bib` 和 `drawing.ps`。
+默认项目包含 `main.tex`、`references.bib` 和 `drawing.ps`。只需编辑时可以双击 `index.html`;需要预览和 PDF 导出时,按以下方式启动本地服务。
+
+### Windows
+
+安装 [Node.js 22 或更新版本](https://nodejs.org/en/download),解压源码包,在该目录打开 PowerShell:
+
+```powershell
+.\setup-windows.ps1
+.\start.ps1
+```
+
+如果 PowerShell 的默认执行策略阻止脚本,可对这一次调用使用 `powershell -NoProfile -ExecutionPolicy Bypass -File .\setup-windows.ps1`,启动脚本同理;无需修改系统执行策略。
+
+安装脚本下载固定版本的 Tectonic 0.17.0、Ghostscript 10.08.0 和解压工具,校验官方发布的 SHA-256 后放入当前目录的 `.tools`。它只解包安装器,不运行安装器、不写注册表、不覆盖项目文件。再次执行会保留已存在的工具;失败时下载和暂存文件会留在 `.tools` 便于检查。第三方引擎未包含在源码包中,各自许可保留在下载包内。
+
+启动后在浏览器打开终端显示的地址,默认是 `http://127.0.0.1:4173`。更换端口可用 `.\start.ps1 -Port 4174`。终端需保持运行;按 Ctrl+C 退出。
+
+### macOS、Linux 或已有工具链
+
+安装 [Tectonic](https://tectonic-typesetting.github.io/book/latest/installation/index.html) 或 XeLaTeX,以及 [Ghostscript](https://ghostscript.com/releases/gsdnld.html),确保可执行文件在 PATH 中,然后运行:
+
+```bash
+node server.mjs
+```
+
+也可用以下环境变量指定已有引擎的**可执行文件路径**:`VISUALLATEX_TECTONIC`、`VISUALLATEX_XELATEX`、`VISUALLATEX_GHOSTSCRIPT`;XeLaTeX 的文献工具可用 `VISUALLATEX_BIBTEX`、`VISUALLATEX_BIBER`。显式设置 XeLaTeX 可选择本机 TeX 发行版;不要同时指定两个 LaTeX 引擎。
+
+Tectonic 首次编译会按需联网下载宏包和字体支持文件,之后可复用缓存;源码在本机编译,不上传到第三方。Windows 启动脚本将缓存放在 `.tools/cache`;也可设置 `TECTONIC_CACHE_DIR`。已有本机 TeX 发行版按其自身方式管理宏包与字体。
+
+### 编辑与生成 PDF
1. 用「添加文件」加入文件,或用「打开文件夹」「打开项目」导入目录/ZIP。
2. 从左侧选择文件,在源码区直接修改。`main.tex` 等完整 LaTeX 文档还可切换到「可视化」。
3. 用「下载文件」导出当前文件,或用「导出 ZIP」保存整个项目及资源。
+4. 选择编译目标:LaTeX 使用完整的主文档,PostScript 使用 `.ps` / `.eps` 文件,点击编译/渲染或按 Ctrl/Cmd+Enter。
+5. 在预览区检查生成的 PDF,使用「导出 PDF」保存结果。编译失败可查看日志,也可取消运行中的任务。
+
+修改源码后,旧 PDF 会标记为过期;导出会先编译当前内容,避免将旧结果误当成最新版本。编译失败时保留上次预览,项目源码保持可编辑。浏览器不支持内嵌 PDF 时仍可下载 PDF 后用本机阅读器打开。
+
+`file://` 和 GitHub Pages 的静态版本只提供编辑,不执行编译。浏览器草稿按来源隔离;从旧离线页面切到本地服务前,请先导出 ZIP,再在本地服务中打开。
打开文件夹或项目包会在确认后替换当前项目;添加文件会保留已有文件,并自动为重名文件另起名称。源码中已有的 `\input`、图片路径和引用键不会因重命名文件而自动重写。
@@ -30,6 +65,9 @@
| 图片等资源 | 二进制文件按原始字节保存;二进制 EPS 保留为资源,可单独下载 |
| 项目 ZIP | 导入 stored/deflate ZIP,导出标准 ZIP;包含未打开文件的编辑及原始资源 |
| 本机草稿 | 整个项目单次保存;失败时显示状态;兼容旧版单文档草稿迁移 |
+| LaTeX 编译 | Tectonic 或 XeLaTeX,带项目文件与资源、引用及参考文献处理 |
+| PostScript 渲染 | Ghostscript 将 PS/EPS 转为 PDF;EPS 按 BoundingBox 裁切 |
+| PDF 预览与导出 | 编译目标选择、取消、错误日志、旧结果标记及当前版本导出 |
在可视化中双击引用命令会定位到源码,直接修改完整命令。双击公式可使用公式编辑窗口。LaTeX 章节、基础格式、列表、公式和简单图片保留可视化支持;复杂结构以可编辑原文块呈现。
@@ -43,6 +81,7 @@
| Ctrl/Cmd + F | 当前文件查找替换 |
| Ctrl/Cmd + Z | 撤销 |
| Ctrl/Cmd + Shift + Z / Ctrl + Y | 重做 |
+| Ctrl/Cmd + Enter | 编译或渲染当前目标 |
| Tab | 在源码中插入两个空格 |
底栏的编码按钮可显式切换 UTF-8 / Latin-1。Latin-1 不能表达的字符不会被静默替换;需要输入这些字符时先切换编码。
@@ -53,8 +92,11 @@
- 浏览器自动保存容量由浏览器决定,可能小于项目导入上限。大项目请使用「导出 ZIP」备份;撤销历史仅保留在当前会话。
- UTF-8 BOM、原始换行和未编辑的 LaTeX 源码块会被保留。可视化改动的区段会重新生成 LaTeX。
- ZIP 中的项目名称、主文件和当前文件记录在 ZIP 注释中,不占用或覆盖用户文件名;第三方重新打包可能移除这些设置,但源码和资源仍可导入。
-- 本版本负责编辑与项目管理,**不执行 TeX 或 PostScript 程序,也不编译/渲染 PDF、PS**。将导出的项目交给现有 TeX / Ghostscript 工具链处理。
-- 不支持 TeX 宏展开或完整语法诊断。可视化能力不足时,完整源码仍可编辑。
+- 编译是显式操作,默认最长 180 秒;初次下载宏包可能较慢。修复错误后可重新编译,日志包含引擎报告的文件名和行号。
+- Tectonic 提供 TeX/BibTeX 自动多遍处理;BibLaTeX/Biber 项目应使用安装了 Biber 的 XeLaTeX 工具链。中文及自定义字体需在工具链中可用。需要 shell-escape 的宏包(例如部分 minted 工作流)不受支持。
+- 字体实测限制:Tectonic 0.17.0 使用默认 ctex/Fandol 字体时,样例 PDF 的中文外观正常,但复制或文本提取的字符映射异常。Windows 上有对应系统字体时,可在自己的文档中显式使用 `\usepackage[UTF8,fontset=windows]{ctex}`;该设置已在此 Windows 样例验证复制正常。应用不会自动修改项目的字体设置,其他平台和字体组合需自行核验。
+- 服务仅绑定本机回环地址,检查 Host、Origin 和会话令牌;临时项目目录在任务结束后清理。Tectonic 使用 `--untrusted`,XeLaTeX 关闭 shell-escape,Ghostscript 使用 `SAFER`。这些选项**不是操作系统沙箱**,请编译可信的本机项目,不要将此服务改成公网编译服务。
+- 可视化编辑不提供完整 TeX 语法分析;正式排版与诊断以编译引擎结果为准。
## 开发与验证
@@ -63,11 +105,16 @@ Node.js 22+,无需安装 npm 包:
```bash
node --check main.js
node --check workspace.js
+node --check preview.js
+node --check compiler.mjs
+node --check server.mjs
node tests/smoke.mjs
node --test
```
-测试覆盖源码往返、HTML 注入、草稿迁移、多文件历史、引用索引、PostScript/资源字节、ZIP 完整性以及实际界面事件。DOM 测试使用最小模型,不代表真实浏览器布局或 TeX/PS 编译验证。
+测试覆盖源码往返、HTML 注入、草稿迁移、多文件历史、引用索引、PostScript/资源字节、ZIP 完整性、编译进程与本地服务边界、取消/过期结果及实际界面事件。常规测试通过模拟引擎验证流程,无需安装工具链;DOM 测试使用最小模型,不代表真实浏览器布局或实际引擎排版验证。
+
+本次真实工具链验证使用 Tectonic 和 Ghostscript;XeLaTeX 分支尚未在真实发行版中验证。
CI 检查 `main` / `dev` 的推送与 PR;部署和发布都在检查通过后执行。
@@ -77,6 +124,9 @@ workspace.js 文件、编辑模式、查找替换与导入导出
project-model.js 源码、项目状态、历史、引用索引
project-archive.js 无依赖 ZIP 读写
main.js / style.css LaTeX 解析、转换及可视化编辑核心
+preview.js / preview.css 编译状态、PDF 预览和导出界面
+compiler.mjs / server.mjs 本机编译工具与回环 HTTP 服务
+setup-windows.ps1 / start.ps1 Windows 便携工具安装与启动
tests/ 零依赖回归测试
```
diff --git a/compiler.mjs b/compiler.mjs
new file mode 100644
index 0000000..7c1abcb
--- /dev/null
+++ b/compiler.mjs
@@ -0,0 +1,289 @@
+// Local compiler bridge. Engine safety switches are not an operating-system sandbox.
+import {spawn} from 'node:child_process';
+import {access, lstat, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile} from 'node:fs/promises';
+import {constants} from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+import './project-model.js';
+
+const ProjectModel = globalThis.ProjectModel;
+const MAX_LOG_BYTES = 512 * 1024;
+const MAX_PDF_BYTES = 50 * 1024 * 1024;
+const DEFAULT_TIMEOUT_MS = 180000;
+const TOOL_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '.tools');
+
+export class CompilationError extends Error {
+ constructor(message, code = 'COMPILE_FAILED', log = '') {
+ super(message);
+ this.name = 'CompilationError';
+ this.code = code;
+ this.log = log;
+ }
+}
+
+function boundedLog(text) {
+ const bytes = Buffer.from(text);
+ if (bytes.length <= MAX_LOG_BYTES) return text;
+ return '[较早日志已省略]\n' + bytes.subarray(-MAX_LOG_BYTES).toString('utf8');
+}
+
+async function executable(candidate) {
+ if (!candidate) return null;
+ try {
+ const resolved = path.resolve(candidate);
+ if (!(await stat(resolved)).isFile()) return null;
+ await access(resolved, constants.X_OK);
+ return resolved;
+ } catch { return null; }
+}
+
+async function findExecutable(names, configured, env, beside, portableDirectory) {
+ if (configured) return executable(configured);
+ const directories = [...(beside ? [beside] : []), ...(portableDirectory ? [portableDirectory] : []), ...(env.PATH || env.Path || '').split(path.delimiter).filter(Boolean)];
+ for (const directory of directories) {
+ for (const name of names) {
+ const found = await executable(path.join(directory.replace(/^"|"$/g, ''), name));
+ if (found) return found;
+ }
+ }
+ return null;
+}
+
+export async function discoverEngines({env = process.env, toolsDirectory = TOOL_ROOT} = {}) {
+ const extension = process.platform === 'win32' ? '.exe' : '';
+ // An explicit XeLaTeX choice takes priority over automatically found Tectonic.
+ const tectonic = env.VISUALLATEX_XELATEX && !env.VISUALLATEX_TECTONIC ? null
+ : await findExecutable(['tectonic' + extension], env.VISUALLATEX_TECTONIC, env, null, path.join(toolsDirectory, 'tectonic'));
+ const xelatex = tectonic ? null : await findExecutable(['xelatex' + extension], env.VISUALLATEX_XELATEX, env);
+ const gs = await findExecutable(process.platform === 'win32' ? ['gswin64c.exe', 'gswin32c.exe', 'gs.exe'] : ['gs'], env.VISUALLATEX_GHOSTSCRIPT, env, null, path.join(toolsDirectory, 'ghostscript', 'bin'));
+ const bibtex = xelatex ? await findExecutable(['bibtex' + extension], env.VISUALLATEX_BIBTEX, env, path.dirname(xelatex)) : null;
+ const biber = xelatex ? await findExecutable(['biber' + extension], env.VISUALLATEX_BIBER, env, path.dirname(xelatex)) : null;
+ return {
+ latex: {available: Boolean(tectonic || xelatex), engine: tectonic ? 'tectonic' : xelatex ? 'xelatex' : null,
+ label: tectonic ? 'Tectonic' : xelatex ? 'XeLaTeX' : '未安装 LaTeX 引擎', executable: tectonic || xelatex, bibtex, biber},
+ postscript: {available: Boolean(gs), engine: gs ? 'ghostscript' : null,
+ label: gs ? 'Ghostscript' : '未安装 Ghostscript', executable: gs}
+ };
+}
+
+async function killTree(child) {
+ if (!child.pid) return;
+ if (process.platform === 'win32') {
+ await new Promise(resolve => {
+ const killer = spawn(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
+ ['/PID', String(child.pid), '/T', '/F'], {windowsHide: true, shell: false, stdio: 'ignore'});
+ killer.once('error', resolve);
+ killer.once('close', resolve);
+ });
+ child.kill();
+ } else {
+ try { process.kill(-child.pid, 'SIGKILL'); }
+ catch { child.kill('SIGKILL'); }
+ }
+}
+
+export function runProcess(command, args, {cwd, env, signal} = {}) {
+ if (signal?.aborted) return Promise.reject(signal.reason || new CompilationError('已取消编译', 'CANCELLED'));
+ return new Promise((resolve, reject) => {
+ let log = '';
+ let aborted = false;
+ const child = spawn(command, args, {cwd, env, windowsHide: true, shell: false,
+ detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe']});
+ const append = data => { log = boundedLog(log + data.toString('utf8')); };
+ child.stdout.setEncoding('utf8');
+ child.stderr.setEncoding('utf8');
+ child.stdout.on('data', append);
+ child.stderr.on('data', append);
+ const abort = () => { aborted = true; void killTree(child); };
+ signal?.addEventListener('abort', abort, {once: true});
+ child.once('error', error => {
+ signal?.removeEventListener('abort', abort);
+ reject(new CompilationError('无法启动编译引擎:' + error.message, 'ENGINE_START_FAILED', log));
+ });
+ child.once('close', (code, processSignal) => {
+ signal?.removeEventListener('abort', abort);
+ if (aborted || signal?.aborted) {
+ const reason = signal?.reason;
+ reject(new CompilationError(reason?.message || '已取消编译', reason?.code || 'CANCELLED', log));
+ } else resolve({code: code ?? -1, signal: processSignal, log});
+ });
+ // Cover an abort arriving between spawn and listener registration.
+ if (signal?.aborted) abort();
+ });
+}
+
+function prepareProject(project, selectedPath, kind) {
+ let model;
+ try { model = ProjectModel.deserialize(project); }
+ catch (error) { throw new CompilationError('项目数据无效:' + error.message, 'INVALID_PROJECT'); }
+ const mainPath = selectedPath || model.mainPath;
+ const main = model.get(mainPath);
+ if (!main) throw new CompilationError('请选择项目中的主文件', 'INVALID_MAIN');
+ if (kind !== 'latex' && kind !== 'postscript') throw new CompilationError('未知编译类型', 'INVALID_KIND');
+ if (kind === 'latex' && (main.kind !== 'text' || !/\.(tex|latex|ltx)$/i.test(main.path))) {
+ throw new CompilationError('LaTeX 主文件必须是 .tex、.latex 或 .ltx 源码', 'INVALID_MAIN');
+ }
+ if (kind === 'postscript' && !/\.(ps|eps)$/i.test(main.path)) {
+ throw new CompilationError('PostScript 主文件必须是 .ps 或 .eps', 'INVALID_MAIN');
+ }
+ const files = model.list();
+ const paths = new Set(files.map(file => file.path.normalize('NFC').toLowerCase()));
+ for (const file of files) {
+ const parts = file.path.split('/');
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ if (/[<>"|?*]/.test(part) || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) {
+ throw new CompilationError('文件名不能安全写入本机:' + file.path, 'INVALID_PATH');
+ }
+ if (kind === 'postscript' && part.includes(path.delimiter)) {
+ throw new CompilationError('PostScript 资源文件名不能包含权限列表分隔符:' + file.path, 'INVALID_PATH');
+ }
+ if (i < parts.length - 1 && paths.has(parts.slice(0, i + 1).join('/').normalize('NFC').toLowerCase())) {
+ throw new CompilationError('文件与目录路径冲突:' + file.path, 'INVALID_PATH');
+ }
+ }
+ }
+ return {files, mainPath: main.path};
+}
+
+function fileBytes(file) {
+ if (file.kind === 'binary') return Buffer.from(file.base64, 'base64');
+ return Buffer.from(file.text, file.encoding === 'latin1' ? 'latin1' : 'utf8');
+}
+
+async function readBounded(file, maximum) {
+ const info = await lstat(file);
+ if (!info.isFile() || info.isSymbolicLink()) throw new CompilationError('引擎输出不是普通文件', 'INVALID_OUTPUT');
+ if (info.size > maximum) throw new CompilationError('编译输出超过大小限制', 'OUTPUT_TOO_LARGE');
+ return readFile(file);
+}
+
+async function savedLog(directory) {
+ try {
+ let result = '';
+ for (const name of await readdir(directory)) {
+ if (!/\.(log|blg)$/i.test(name)) continue;
+ const target = path.join(directory, name);
+ const info = await lstat(target);
+ if (!info.isFile() || info.isSymbolicLink()) continue;
+ const handle = await open(target, 'r');
+ try {
+ const bytes = Buffer.alloc(Math.min(info.size, MAX_LOG_BYTES));
+ await handle.read(bytes, 0, bytes.length, Math.max(0, info.size - bytes.length));
+ result = boundedLog(result + '\n--- ' + name + ' ---\n' + bytes.toString('utf8'));
+ } finally { await handle.close(); }
+ }
+ return result;
+ } catch { return ''; }
+}
+
+export async function compileProject(project, options = {}) {
+ const {kind, signal, runner = runProcess, timeoutMs = DEFAULT_TIMEOUT_MS} = options;
+ const validated = prepareProject(project, options.mainPath, kind);
+ if (signal?.aborted) throw new CompilationError('已取消编译', 'CANCELLED');
+ const engines = options.engines || await discoverEngines();
+ const engine = engines[kind];
+ if (!engine?.available || !engine.executable) throw new CompilationError(kind === 'latex' ? '未找到 LaTeX 引擎,请安装 Tectonic 或 XeLaTeX 并重启服务' : '未找到 Ghostscript,请安装后重启服务', 'ENGINE_MISSING');
+ const temporaryParent = path.resolve(options.temporaryParent || os.tmpdir());
+ const jobDirectory = await mkdtemp(path.join(temporaryParent, 'visuallatex-compile-'));
+ const sourceDirectory = path.join(jobDirectory, 'project');
+ const outputDirectory = path.join(jobDirectory, 'output');
+ const mainFile = path.resolve(sourceDirectory, validated.mainPath);
+ const cwd = path.dirname(mainFile);
+ const abort = new AbortController();
+ const forwardAbort = () => abort.abort(new CompilationError('已取消编译', 'CANCELLED'));
+ signal?.addEventListener('abort', forwardAbort, {once: true});
+ if (signal?.aborted) forwardAbort();
+ const timeout = setTimeout(() => abort.abort(new CompilationError('编译超时,请检查无限循环或过大的项目', 'COMPILE_TIMEOUT')), Math.max(1, Math.min(timeoutMs, 600000)));
+ const env = {...process.env, TECTONIC_UNTRUSTED_MODE: '1', shell_escape: '0', openout_any: 'p', openin_any: 'p',
+ TEXMFOUTPUT: outputDirectory, TEXMF_OUTPUT_DIRECTORY: outputDirectory, TMPDIR: jobDirectory, TEMP: jobDirectory, TMP: jobDirectory};
+ if (!env.TECTONIC_CACHE_DIR && await stat(path.join(TOOL_ROOT, 'cache')).then(info => info.isDirectory(), () => false)) {
+ env.TECTONIC_CACHE_DIR = path.join(TOOL_ROOT, 'cache');
+ }
+ // Avoid inherited options that could silently change Ghostscript safety or output.
+ delete env.GS_OPTIONS;
+ let log = '';
+ const run = async (command, args, workingDirectory = cwd, extraEnv = {}) => {
+ if (abort.signal.aborted) throw abort.signal.reason;
+ const result = await runner(command, args, {cwd: workingDirectory, env: {...env, ...extraEnv}, signal: abort.signal, outputDirectory, sourceDirectory});
+ log = boundedLog(log + '\n[' + path.basename(command) + ']\n' + (result.log || ''));
+ if (abort.signal.aborted) throw abort.signal.reason;
+ if (result.code !== 0) throw new CompilationError('编译失败,请查看日志中的文件名、行号和错误信息', 'COMPILE_FAILED');
+ };
+ try {
+ await mkdir(sourceDirectory);
+ await mkdir(outputDirectory);
+ for (const file of validated.files) {
+ if (abort.signal.aborted) throw abort.signal.reason;
+ const target = path.resolve(sourceDirectory, file.path);
+ if (!target.startsWith(sourceDirectory + path.sep)) throw new CompilationError('文件路径越出项目', 'INVALID_PATH');
+ await mkdir(path.dirname(target), {recursive: true});
+ await writeFile(target, fileBytes(file), {flag: 'wx'});
+ }
+ let output;
+ if (kind === 'postscript') {
+ output = path.join(outputDirectory, 'document.pdf');
+ const portable = value => value.replace(/\\/g, '/');
+ const args = ['-dSAFER', '-dBATCH', '-dNOPAUSE', '-sDEVICE=pdfwrite', '-dAutoRotatePages=/None',
+ '--permit-file-read=' + portable(sourceDirectory) + '/*', '--permit-file-write=' + portable(outputDirectory) + '/*',
+ '-sOutputFile=' + portable(output)];
+ // Ghostscript matches the requested filename form exactly: a grant
+ // for an absolute directory does not permit `(parts/figure.ps) run`.
+ // Grant only staged project files in the two usual relative forms.
+ for (const file of validated.files) {
+ const relative = portable(path.relative(cwd, path.resolve(sourceDirectory, file.path)));
+ args.push('--permit-file-read=' + relative, '--permit-file-read=./' + relative);
+ }
+ if (/\.eps$/i.test(mainFile)) args.push('-dEPSCrop');
+ args.push('-f', portable(mainFile));
+ await run(engine.executable, args);
+ } else if (engine.engine === 'tectonic') {
+ output = path.join(outputDirectory, path.parse(mainFile).name + '.pdf');
+ let tectonicError;
+ try { await run(engine.executable, ['-X', 'compile', '--untrusted', '--keep-logs', '--keep-intermediates', '--outdir', outputDirectory, mainFile]); }
+ catch (error) { tectonicError = error; }
+ const bcf = await access(path.join(outputDirectory, path.parse(mainFile).name + '.bcf')).then(() => true, () => false);
+ if (bcf || /note: Running external tool biber\b/.test(log)) {
+ throw new CompilationError('项目需要 Biber;请配置 XeLaTeX 与 biber 后重新编译,当前 Tectonic 流程不支持此项目', 'BIBLIOGRAPHY_ENGINE_MISSING');
+ }
+ if (tectonicError) throw tectonicError;
+ } else if (engine.engine === 'xelatex') {
+ output = path.join(outputDirectory, 'visual-workspace.pdf');
+ const args = ['-no-shell-escape', '-no-parse-first-line', '-interaction=nonstopmode', '-halt-on-error', '-file-line-error',
+ '-jobname=visual-workspace', '-output-directory=' + outputDirectory, mainFile];
+ await run(engine.executable, args);
+ const aux = await readFile(path.join(outputDirectory, 'visual-workspace.aux'), 'utf8').catch(() => '');
+ const bcf = await access(path.join(outputDirectory, 'visual-workspace.bcf')).then(() => true, () => false);
+ const searchPath = cwd + path.sep + path.delimiter + sourceDirectory + path.sep + path.delimiter;
+ if (bcf) {
+ if (!engine.biber) throw new CompilationError('项目使用 BibLaTeX/Biber,但未找到 biber 引擎', 'BIBLIOGRAPHY_ENGINE_MISSING');
+ await run(engine.biber, ['--input-directory', outputDirectory, '--output-directory', outputDirectory, 'visual-workspace'], cwd,
+ {BIBINPUTS: searchPath});
+ } else if (/\\bibdata\{/.test(aux)) {
+ if (!engine.bibtex) throw new CompilationError('项目使用 BibTeX,但未找到 bibtex 引擎', 'BIBLIOGRAPHY_ENGINE_MISSING');
+ await run(engine.bibtex, ['visual-workspace'], outputDirectory, {BIBINPUTS: searchPath, BSTINPUTS: searchPath});
+ }
+ await run(engine.executable, args);
+ await run(engine.executable, args);
+ } else throw new CompilationError('不支持此编译引擎', 'ENGINE_UNSUPPORTED');
+ const pdf = await readBounded(output, MAX_PDF_BYTES).catch(error => {
+ if (error.code === 'ENOENT') throw new CompilationError('引擎未生成 PDF,请检查日志', 'PDF_MISSING');
+ throw error;
+ });
+ if (!pdf.subarray(0, 5).equals(Buffer.from('%PDF-'))) throw new CompilationError('引擎返回的文件不是有效 PDF', 'INVALID_OUTPUT');
+ log = boundedLog(log + await savedLog(outputDirectory));
+ return {pdf, log, engine: engine.label || engine.engine, mainPath: validated.mainPath};
+ } catch (error) {
+ log = boundedLog(log + (error.log || '') + await savedLog(outputDirectory));
+ const reason = abort.signal.aborted ? abort.signal.reason : error;
+ throw new CompilationError(reason.message, reason.code || 'COMPILE_FAILED', log);
+ } finally {
+ clearTimeout(timeout);
+ signal?.removeEventListener('abort', forwardAbort);
+ // This path is created by mkdtemp above, never provided by a project file.
+ if (path.dirname(jobDirectory) === temporaryParent && path.basename(jobDirectory).startsWith('visuallatex-compile-')) {
+ await rm(jobDirectory, {recursive: true, force: true, maxRetries: 3});
+ }
+ }
+}
diff --git a/index.html b/index.html
index f6f6fdf..8317379 100644
--- a/index.html
+++ b/index.html
@@ -6,6 +6,7 @@
VisualLaTeX Studio · LaTeX 与 PostScript 工作台
+
+
+
+
+
diff --git a/main.js b/main.js
index 53812cb..a7d23de 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();
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..ce9531c
--- /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', '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/tests/project-archive.test.mjs b/tests/project-archive.test.mjs
new file mode 100644
index 0000000..8b7faee
--- /dev/null
+++ b/tests/project-archive.test.mjs
@@ -0,0 +1,198 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import { deflateRawSync } from 'node:zlib';
+
+const context = vm.createContext({TextEncoder, TextDecoder, Uint8Array, Uint32Array, DataView, Blob, DecompressionStream});
+vm.runInContext(fs.readFileSync(new URL('../project-archive.js', import.meta.url), 'utf8'), context);
+const zip = context.ProjectArchive;
+const encode = text => new TextEncoder().encode(text);
+const decode = bytes => new TextDecoder().decode(bytes);
+
+// Build a single ZIP member with independent zlib compression and optional streaming descriptor.
+function fixture(path, text, {method = 8, descriptor = false, directory = false} = {}) {
+ const name = encode(directory ? path + '/' : path);
+ const original = typeof text === 'string' ? encode(text) : text;
+ const payload = method === 8 ? new Uint8Array(deflateRawSync(original)) : original;
+ const flags = 0x0800 | (descriptor ? 8 : 0);
+ const dataStart = 30 + name.length;
+ const central = dataStart + payload.length + (descriptor ? 16 : 0);
+ const end = central + 46 + name.length;
+ const bytes = new Uint8Array(end + 22);
+ const view = new DataView(bytes.buffer);
+ const crc = zip.crc32(original);
+ view.setUint32(0, 0x04034b50, true);
+ view.setUint16(4, 20, true);
+ view.setUint16(6, flags, true);
+ view.setUint16(8, method, true);
+ if (!descriptor) {
+ view.setUint32(14, crc, true);
+ view.setUint32(18, payload.length, true);
+ view.setUint32(22, original.length, true);
+ }
+ view.setUint16(26, name.length, true);
+ bytes.set(name, 30);
+ bytes.set(payload, dataStart);
+ if (descriptor) {
+ const offset = dataStart + payload.length;
+ view.setUint32(offset, 0x08074b50, true);
+ view.setUint32(offset + 4, crc, true);
+ view.setUint32(offset + 8, payload.length, true);
+ view.setUint32(offset + 12, original.length, true);
+ }
+ view.setUint32(central, 0x02014b50, true);
+ view.setUint16(central + 4, 20, true);
+ view.setUint16(central + 6, 20, true);
+ view.setUint16(central + 8, flags, true);
+ view.setUint16(central + 10, method, true);
+ view.setUint32(central + 16, crc, true);
+ view.setUint32(central + 20, payload.length, true);
+ view.setUint32(central + 24, original.length, true);
+ view.setUint16(central + 28, name.length, true);
+ bytes.set(name, central + 46);
+ view.setUint32(end, 0x06054b50, true);
+ view.setUint16(end + 8, 1, true);
+ view.setUint16(end + 10, 1, true);
+ view.setUint32(end + 12, 46 + name.length, true);
+ view.setUint32(end + 16, central, true);
+ return {bytes, view, central, end, dataStart};
+}
+
+test('ZIP project export and import preserve paths, UTF-8 text, CRLF, and binary resources', async () => {
+ const files = [
+ {path: 'main.tex', bytes: encode('\\documentclass{article}\r\n未完成 \\cite{')},
+ {path: '文献/references.bib', bytes: encode('@book{key, title={A}}')},
+ {path: 'figures/example.ps', bytes: encode('%!PS-Adobe-3.0\n100 100 moveto\nshowpage\n')},
+ {path: 'assets/image.png', bytes: Uint8Array.from([0, 255, 137, 13, 10, 0, 128])},
+ ];
+ const result = await zip.readZip(zip.writeZip(files));
+ assert.equal(result.length, files.length);
+ result.forEach((file, index) => {
+ assert.equal(file.path, files[index].path);
+ assert.deepEqual(file.bytes, files[index].bytes);
+ });
+ assert.deepEqual(zip.writeZip(files), zip.writeZip(files), 'exports are deterministic');
+});
+
+test('empty ZIP and zero-byte files are valid', async () => {
+ assert.equal((await zip.readZip(zip.writeZip([]))).length, 0);
+ assert.equal((await zip.readZip(zip.writeZip([{path:'empty.tex',bytes:new Uint8Array()}])))[0].bytes.length, 0);
+});
+
+test('UTF-8 filename decoding does not drop a leading BOM character', async () => {
+ const name = '\ufeffnamed.tex';
+ const files = await zip.readZip(zip.writeZip([{path:name,bytes:encode('content')}]));
+ assert.equal(files[0].path, name);
+});
+
+test('ZIP reads deflate compressed projects and streaming data descriptors', async () => {
+ for (const descriptor of [false, true]) {
+ const source = 'PostScript % keep\r\n'.repeat(500);
+ const result = await zip.readZip(fixture('drawing.ps', source, {descriptor}).bytes);
+ assert.equal(result[0].path, 'drawing.ps');
+ assert.equal(decode(result[0].bytes), source);
+ }
+});
+
+test('safe directory entries are ignored', async () => {
+ assert.equal((await zip.readZip(fixture('figures', '', {directory:true, method:0}).bytes)).length, 0);
+});
+
+test('path traversal, absolute paths, control characters and normalization duplicates are rejected', async () => {
+ for (const path of ['../x.tex', '/x.tex', 'C:/x.tex', 'a/../x.tex', 'a/./x.tex', 'a//x.tex', 'x\0.tex', 'a:b.tex']) {
+ assert.throws(() => zip.writeZip([{path,bytes:encode('x')}]));
+ await assert.rejects(zip.readZip(fixture(path, 'x').bytes));
+ }
+ assert.throws(() => zip.writeZip([{path:'A.tex',bytes:encode('a')},{path:'a.tex',bytes:encode('b')}]));
+ assert.throws(() => zip.writeZip([{path:'a\\b.tex',bytes:encode('a')},{path:'a/b.tex',bytes:encode('b')}]));
+ assert.equal(zip.validatePath('目录\\main.tex'), '目录/main.tex');
+});
+
+test('CRC corruption, mismatched local headers and truncated archives fail without partial output', async () => {
+ const corrupted = fixture('a.tex', 'content', {method:0});
+ corrupted.bytes[corrupted.dataStart] ^= 1;
+ await assert.rejects(zip.readZip(corrupted.bytes), /校验/);
+ const nameMismatch = fixture('a.tex', 'content');
+ nameMismatch.bytes[30] = 98;
+ await assert.rejects(zip.readZip(nameMismatch.bytes), /名称/);
+ const sizes = fixture('a.tex', 'content');
+ sizes.view.setUint32(22, 999, true);
+ await assert.rejects(zip.readZip(sizes.bytes), /不一致/);
+ await assert.rejects(zip.readZip(fixture('a.tex', 'content').bytes.slice(0, -1)), /目录/);
+});
+
+test('encrypted, multi-disk, ZIP64 and unsupported compression are rejected', async () => {
+ const encrypted = fixture('a.tex', 'x');
+ encrypted.view.setUint16(encrypted.central + 8, 0x0801, true);
+ await assert.rejects(zip.readZip(encrypted.bytes), /加密/);
+ const multidisk = fixture('a.tex', 'x');
+ multidisk.view.setUint16(multidisk.end + 4, 1, true);
+ await assert.rejects(zip.readZip(multidisk.bytes), /分卷/);
+ const large = fixture('a.tex', 'x');
+ large.view.setUint32(large.central + 24, 0xffffffff, true);
+ await assert.rejects(zip.readZip(large.bytes), /ZIP64/);
+ const method = fixture('a.tex', 'x');
+ method.view.setUint16(method.central + 10, 12, true);
+ await assert.rejects(zip.readZip(method.bytes), /压缩方式/);
+});
+
+test('project count and declared expanded size limits are enforced before decompression', async () => {
+ assert.throws(() => zip.writeZip(Array.from({length:501}, (_,n)=>({path:n+'.tex',bytes:encode('')}))), /500/);
+ const bomb = fixture('a.tex', 'x');
+ bomb.view.setUint32(bomb.central + 24, zip.MAX_TOTAL_BYTES + 1, true);
+ await assert.rejects(zip.readZip(bomb.bytes), /50 MB/);
+});
+
+test('decompression cannot exceed a falsely small declared size', async () => {
+ const bomb = fixture('a.tex', 'x'.repeat(100000));
+ bomb.view.setUint32(22, 1, true);
+ bomb.view.setUint32(bomb.central + 24, 1, true);
+ await assert.rejects(zip.readZip(bomb.bytes), /超过声明大小/);
+});
+
+test('CRC32 matches the standard reference vector', () => {
+ assert.equal(zip.crc32(encode('123456789')), 0xcbf43926);
+});
+
+test('ZIP comments roundtrip Unicode metadata without changing the default array API', async () => {
+ const comment = JSON.stringify({name:'排版项目 📄',mainPath:'图形/example.ps'});
+ const source = '%!PS-Adobe-3.0\r\nshowpage\r\n';
+ const bytes = zip.writeZip([{path:'图形/example.ps',bytes:encode(source)}], {comment});
+ const defaultResult = await zip.readZip(bytes);
+ assert.equal(Array.isArray(defaultResult), true);
+ assert.equal(defaultResult.length, 1);
+ assert.equal(decode(defaultResult[0].bytes), source);
+ const detailed = await zip.readZip(bytes, {includeComment:true});
+ assert.equal(detailed.comment, comment);
+ assert.equal(detailed.files[0].path, '图形/example.ps');
+ assert.equal(decode(detailed.files[0].bytes), source);
+ const empty = await zip.readZip(zip.writeZip([]), {includeComment:true});
+ assert.equal(empty.files.length, 0);
+ assert.equal(empty.comment, '');
+});
+
+test('ZIP comments enforce the 65535-byte UTF-8 boundary rather than character count', async () => {
+ const accepted = '中'.repeat(21845);
+ assert.equal(encode(accepted).length, 65535);
+ const detailed = await zip.readZip(zip.writeZip([], {comment:accepted}), {includeComment:true});
+ assert.equal(detailed.comment, accepted);
+ assert.throws(() => zip.writeZip([], {comment:accepted + '中'}));
+ assert.throws(() => zip.writeZip([], {comment:'a'.repeat(65536)}));
+});
+
+test('non-UTF-8 external comments use replacement decoding without losing project files', async () => {
+ const external = fixture('main.tex', 'Unchanged \\cite{key}', {method:0});
+ const comment = Uint8Array.from([0x43, 0x61, 0x66, 0xe9]);
+ const bytes = new Uint8Array(external.bytes.length + comment.length);
+ bytes.set(external.bytes);
+ bytes.set(comment, external.bytes.length);
+ new DataView(bytes.buffer).setUint16(external.end + 20, comment.length, true);
+ const detailed = await zip.readZip(bytes, {includeComment:true});
+ assert.equal(detailed.comment, 'Caf\ufffd');
+ assert.equal(detailed.files.length, 1);
+ assert.equal(detailed.files[0].path, 'main.tex');
+ assert.equal(decode(detailed.files[0].bytes), 'Unchanged \\cite{key}');
+ const defaultResult = await zip.readZip(bytes);
+ assert.equal(defaultResult.length, 1);
+});
diff --git a/tests/project-model.test.mjs b/tests/project-model.test.mjs
new file mode 100644
index 0000000..3cb0077
--- /dev/null
+++ b/tests/project-model.test.mjs
@@ -0,0 +1,201 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+
+const context = vm.createContext({TextEncoder});
+vm.runInContext(fs.readFileSync(new URL('../project-model.js', import.meta.url), 'utf8'), context);
+const ProjectModel = context.ProjectModel;
+const textFile = (path, text = '') => ({path, kind: 'text', text});
+const plain = value => JSON.parse(JSON.stringify(value));
+
+test('all text formats preserve exact content, including incomplete source and CRLF', () => {
+ const files = [textFile('main.tex', '\ufeff% header\r\n\\cite{unfinished'), textFile('refs.bib', '@article{key,\r\n'),
+ textFile('graphics/image.eps', '%!PS-Adobe-3.0 EPSF-3.0\r\n%%BoundingBox: 0 0 100 100\r\n')];
+ const model = new ProjectModel({name: 'Paper', files, activePath: 'refs.bib', mainPath: 'main.tex'});
+ const restored = ProjectModel.deserialize(model.serialize());
+ assert.equal(restored.activePath, 'refs.bib');
+ assert.equal(restored.mainPath, 'main.tex');
+ assert.deepEqual(restored.list().map(f => f.text).join('|'), files.map(f => f.text).join('|'));
+ assert.equal(restored.get('graphics/image.eps').language, 'postscript');
+});
+
+test('recognizes LaTeX, BibTeX and PostScript extensions without changing filenames', () => {
+ assert.equal(ProjectModel.language('a.TEX'), 'latex');
+ assert.equal(ProjectModel.language('style.cls'), 'latex');
+ assert.equal(ProjectModel.language('ref.bib'), 'bibtex');
+ assert.equal(ProjectModel.language('drawing.PS'), 'postscript');
+ assert.equal(ProjectModel.language('drawing.eps'), 'postscript');
+ assert.equal(ProjectModel.language('notes.md'), 'text');
+});
+
+test('binary resources retain exact base64 through project serialization', () => {
+ const binary = {path: 'images/picture.png', kind: 'binary', base64: 'AP9BQgo='};
+ const model = ProjectModel.deserialize(new ProjectModel({files: [binary]}).serialize());
+ assert.equal(model.get().base64, binary.base64);
+ assert.equal(model.totalBytes, 5);
+ assert.equal(model.mainPath, null);
+ assert.throws(() => model.update('oops'), /文本文件/);
+});
+
+test('invalid paths and case-insensitive duplicates cannot enter projects', () => {
+ for (const path of ['/absolute.tex', '../escape.tex', 'a/../b.tex', 'a/./b.tex', 'a//b.tex', 'C:\\file.tex', 'file\0.tex', 'scheme:file.tex']) {
+ assert.throws(() => new ProjectModel({files: [textFile(path)]}));
+ }
+ assert.equal(ProjectModel.validatePath('chapters\\intro.tex'), 'chapters/intro.tex');
+ const model = new ProjectModel({files: [textFile('Main.tex')]});
+ assert.throws(() => model.add(textFile('main.tex')), /已存在/);
+ assert.equal(model.list().length, 1);
+ model.add(textFile('café.tex'));
+ assert.throws(() => model.add(textFile('cafe\u0301.tex')), /已存在/);
+});
+
+test('batch import validates everything before adding any file', () => {
+ const model = new ProjectModel({files: [textFile('main.tex', 'original')]});
+ assert.throws(() => model.addMany([textFile('ok.tex'), textFile('../bad.tex')]));
+ assert.deepEqual(plain(model.list().map(f => f.path)), ['main.tex']);
+ assert.throws(() => model.addMany([textFile('new.tex'), textFile('NEW.tex')]));
+ assert.equal(model.list().length, 1);
+});
+
+test('file mutations update active and main paths while keeping text/history', () => {
+ const model = new ProjectModel({files: [textFile('main.tex', 'old'), textFile('refs.bib', 'bib')]});
+ model.update('new');
+ model.rename('main.tex', 'chapters/main.tex');
+ assert.equal(model.activePath, 'chapters/main.tex');
+ assert.equal(model.mainPath, 'chapters/main.tex');
+ assert.equal(model.undo().text, 'old');
+ model.remove('chapters/main.tex');
+ assert.equal(model.activePath, 'refs.bib');
+ assert.equal(model.mainPath, 'refs.bib');
+ model.remove('refs.bib');
+ assert.equal(model.activePath, null);
+ assert.equal(model.get(), null);
+ assert.equal(ProjectModel.deserialize(model.serialize()).list().length, 0);
+});
+
+test('history is isolated by file and redo is invalidated by a new edit', () => {
+ const model = new ProjectModel({files: [textFile('a.tex', 'A'), textFile('b.ps', 'B')]});
+ model.update('A1');
+ model.select('b.ps');
+ model.update('B1');
+ assert.equal(model.undo().text, 'B');
+ model.select('a.tex');
+ assert.equal(model.undo().text, 'A');
+ assert.equal(model.redo().text, 'A1');
+ model.undo();
+ model.update('A2');
+ assert.equal(model.canRedo(), false);
+ model.select('b.ps');
+ assert.equal(model.redo().text, 'B1');
+});
+
+test('grouped typing forms one undo step and selection ends the group', () => {
+ const model = new ProjectModel({files: [textFile('a.tex', ''), textFile('b.tex', '')]});
+ model.update('a', {group: true});
+ model.update('ab', {group: true});
+ model.update('abc', {group: true});
+ assert.equal(model.undo().text, '');
+ assert.equal(model.undo(), null);
+ model.redo();
+ model.select('b.tex');
+ model.select('a.tex');
+ model.update('abcd', {group: true});
+ assert.equal(model.undo().text, 'abc');
+});
+
+test('model snapshots cannot be changed by mutating returned records', () => {
+ const model = new ProjectModel({files: [textFile('main.tex', 'kept')]});
+ model.get().text = 'not saved';
+ model.list()[0].text = 'also not saved';
+ assert.equal(model.get().text, 'kept');
+});
+
+test('invalid file records and unsupported project versions are rejected', () => {
+ assert.throws(() => new ProjectModel({files: [{path: 'a', kind: 'binary', base64: 'bad!'}]}));
+ assert.throws(() => new ProjectModel({files: [{path: 'a', kind: 'text', text: null}]}));
+ assert.throws(() => ProjectModel.deserialize('{broken'));
+ assert.throws(() => ProjectModel.deserialize({format: 'visual-workspace', version: 99, files: []}));
+ assert.throws(() => new ProjectModel({files: [textFile('a')], activePath: 'missing'}));
+});
+
+test('Latin-1 editing refuses unencodable text without changing content or history', () => {
+ const model = new ProjectModel({files: [{path: 'legacy.ps', kind: 'text', encoding: 'latin1', text: '% café\r\n'}]});
+ assert.throws(() => model.update('% 中文'), /Latin-1/);
+ assert.equal(model.get().text, '% café\r\n');
+ assert.equal(model.canUndo(), false);
+ model.update('% été\r\n');
+ assert.equal(model.get().encoding, 'latin1');
+});
+
+test('incomplete Unicode cannot be silently converted into replacement bytes', () => {
+ const model = new ProjectModel({files: [textFile('main.tex', '😀')]});
+ assert.throws(() => model.update('\ud800'), /Unicode/);
+ assert.equal(model.get().text, '😀');
+});
+
+test('reference index finds live labels and citations with optional args across files', () => {
+ const content = String.raw`% \label{hidden}
+\section{Title}\label{sec:intro}
+See \ref{sec:intro}, \Cref{fig:one,fig:two}; \cite[see][p. 2]{alpha,beta}.
+\footnote{nested \citep{gamma}}
+\verb|\cite{literal}|
+\begin{lstlisting}
+\cite{code}
+\end{lstlisting}
+\cite{unfinished`;
+ const model = new ProjectModel({files: [textFile('main.tex', content), textFile('chapter.tex', '\\label{fig:one}'), textFile('draw.ps', '\\cite{postscript}') ]});
+ const refs = plain(model.references());
+ assert.deepEqual(refs.labels.map(x => x.key), ['sec:intro', 'fig:one']);
+ assert.deepEqual(refs.citations.map(x => x.key), ['alpha', 'beta', 'gamma']);
+ assert.deepEqual(refs.references.map(x => x.key), ['sec:intro', 'fig:one', 'fig:two']);
+ assert.equal(content.slice(refs.citations[0].offset, refs.citations[0].offset + 5), '\\cite');
+});
+
+test('BibTeX index ignores comments and keeps entry locations for navigation', () => {
+ const bib = '% @article{hidden, title={No}}\n@comment{ @article{fake, title={No}} }\n@string{venue="Test"}\n' +
+ '@article{alpha, title={Nested {title}}, year={2026}}\n@book(beta, title="Book")\n';
+ const model = new ProjectModel({files: [textFile('references.bib', bib)]});
+ const entries = plain(model.references().bibliography);
+ assert.deepEqual(entries.map(x => [x.key, x.type]), [['alpha', 'article'], ['beta', 'book']]);
+ assert.equal(bib[entries[0].offset], '@');
+});
+
+test('history limit prevents unbounded edit snapshots', () => {
+ const model = new ProjectModel({files: [textFile('a.tex', '0')]});
+ for (let i = 1; i <= 110; i++) model.update(String(i));
+ let undos = 0;
+ while (model.undo()) undos++;
+ assert.equal(undos, 100);
+ assert.equal(model.get().text, '10');
+});
+
+test('project file count is bounded and rejection remains atomic', () => {
+ const model = new ProjectModel();
+ assert.throws(() => model.addMany(Array.from({length: 501}, (_, n) => textFile(n + '.tex'))), /500/);
+ assert.equal(model.list().length, 0);
+});
+
+test('renaming a project validates names before replacing the current name', () => {
+ const model = new ProjectModel({name: 'Keep'});
+ assert.throws(() => { model.name = ' '; }, /名称/);
+ assert.equal(model.name, 'Keep');
+ model.name = 'Updated project';
+ assert.equal(ProjectModel.deserialize(model.serialize()).name, 'Updated project');
+});
+
+test('explicit encoding conversion preserves text and permits Unicode only after UTF-8 conversion', () => {
+ const model = new ProjectModel({files: [{path: 'legacy.ps', kind: 'text', encoding: 'latin1', text: '% café\r\n'}]});
+ const priorSize = model.totalBytes;
+ model.setEncoding('legacy.ps', 'utf-8');
+ assert.equal(model.get().text, '% café\r\n');
+ assert.equal(model.totalBytes, priorSize + 1);
+ model.update('% 中文\r\n');
+ assert.throws(() => model.setEncoding('legacy.ps', 'latin1'), /Latin-1/);
+ assert.equal(model.get().encoding, 'utf-8');
+ assert.equal(model.get().text, '% 中文\r\n');
+ assert.equal(model.undo().text, '% café\r\n');
+ model.setEncoding('legacy.ps', 'latin1');
+ assert.equal(model.totalBytes, priorSize);
+ assert.equal(model.canUndo(), false);
+});
diff --git a/tests/workspace-controller.test.mjs b/tests/workspace-controller.test.mjs
new file mode 100644
index 0000000..59076fb
--- /dev/null
+++ b/tests/workspace-controller.test.mjs
@@ -0,0 +1,237 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import {createDocument, Element} from './helpers/dom.mjs';
+
+// Uses the actual source-first controller and actual LaTeX importer/exporter.
+// The DOM fixture has no layout, rendering, script execution or browser event loop.
+const scripts = ['main.js', 'project-model.js', 'workspace.js'].map(name => fs.readFileSync(new URL('../' + name, import.meta.url), 'utf8'));
+const tex = body => '\\documentclass{article}\n\\begin{document}\n' + body + '\n\\end{document}\n';
+const textFile = (path, text) => ({path, kind: 'text', text, encoding: 'utf-8'});
+
+function fixture(files, options = {}) {
+ const document = createDocument();
+ const visual = new Element('div');
+ const preview = new Element('div');
+ const status = new Element('span');
+ const notices = [];
+ const data = new Map();
+ const controls = {failWrites: false};
+ const context = vm.createContext({document, Node: {ELEMENT_NODE: 1, TEXT_NODE: 3},
+ TextEncoder, TextDecoder, Uint8Array, atob, btoa, console,
+ alert: message => notices.push(message),
+ __visual: visual, __preview: preview, __status: status});
+ for (const script of scripts) vm.runInContext(script, context);
+ vm.runInContext('editorArea=__visual;previewArea=__preview;statusText=__status;updatePreview=()=>{};saveDraft=()=>true;', context);
+ const sourceArea = {
+ _value: '',
+ get value() { return this._value; },
+ set value(value) { this._value = String(value).replace(/\r\n?/g, '\n'); },
+ };
+ const ui = {sourceArea, saveState: {textContent: ''}};
+ const storage = {getItem: key => data.get(key) ?? null, setItem: (key, value) => {
+ if (controls.failWrites) throw new Error('QuotaExceededError');
+ data.set(key, value);
+ }};
+ const model = new context.ProjectModel({files, ...options});
+ const Controller = vm.runInContext('WorkspaceController', context);
+ const core = {canVisual: source => Boolean(context.splitTexDocument(source)),
+ loadVisual: (source, name) => context.applyImportedTex(source, name),
+ exportVisual: () => context.domToLatex()};
+ const app = new Controller({model, ui, core, storage, notify: message => notices.push(message)});
+ app.show();
+ return {app, ui, visual, data, controls, context, notices};
+}
+
+test('source editing preserves incomplete LaTeX and persists it without a document wrapper', () => {
+ const f = fixture([textFile('main.tex', tex('Original'))]);
+ const incomplete = String.raw`\documentclass{report}
+\usepackage{custom}
+\begin{document}
+\cite{unfinished`;
+ f.ui.sourceArea.value = incomplete;
+ f.app.changed(false);
+ assert.equal(f.app.model.get().text, incomplete);
+ assert.equal(f.app.persist(), true);
+ const restored = f.context.ProjectModel.deserialize(f.data.get('visuallatex_workspace_v1'));
+ assert.equal(restored.get().text, incomplete);
+ const exported = f.app.exportEntries().find(file => file.path === 'main.tex');
+ assert.equal(new TextDecoder().decode(exported.bytes), incomplete);
+});
+
+test('a rejected visual switch does not replace incomplete source or clear the file', () => {
+ const f = fixture([textFile('fragment.tex', '\\cite{')]);
+ assert.equal(f.app.setMode('visual'), false);
+ assert.equal(f.app.mode, 'source');
+ assert.equal(f.app.model.get().text, '\\cite{');
+ assert.equal(f.ui.sourceArea.value, '\\cite{');
+ assert.equal(f.visual.childNodes.length, 0);
+ assert.match(f.notices[0], /源码/);
+});
+
+for (const [path, source] of [
+ ['drawing.ps', '%!PS-Adobe-3.0\n/Helvetica findfont 18 scalefont setfont\n(Hello) show\nshowpage\n'],
+ ['drawing.eps', '%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n'],
+ ['references.bib', '@article{key, title={Title}}\n'],
+ ['chapter.tex', '\\section{Standalone fragment}\nBody.'],
+]) {
+ test(path + ' stays editable source and never gains LaTeX document framing', () => {
+ const f = fixture([textFile(path, source)]);
+ assert.equal(f.app.setMode('visual'), false);
+ const edited = source + '% edited\n';
+ f.ui.sourceArea.value = edited;
+ f.app.changed(false);
+ const exported = f.app.exportEntries().find(file => file.path === path);
+ assert.equal(new TextDecoder().decode(exported.bytes), edited);
+ assert.doesNotMatch(f.app.model.get().text, /\\documentclass/);
+ });
+}
+
+test('source/visual switching preserves exact CRLF and source edits remain canonical', () => {
+ const source = '% untouched prefix\r\n\\documentclass{article}\r\n\\begin{document}\r\nRate 50\\%, \\cite{old}.\r\n\\end{document}\r\n% tail\r\n';
+ const f = fixture([textFile('main.tex', source)]);
+ assert.ok(!f.ui.sourceArea.value.includes('\r'), 'fixture models textarea newline normalization');
+ f.app.changed(false);
+ assert.equal(f.app.model.get().text, source);
+ assert.equal(f.app.setMode('visual'), true);
+ assert.equal(f.app.setMode('source'), true);
+ assert.equal(f.app.model.get().text, source);
+ f.ui.sourceArea.value = f.ui.sourceArea.value.replace('\\cite{old}', '\\cite[see][p.2]{new,second}');
+ f.app.changed(false);
+ const edited = source.replace('\\cite{old}', '\\cite[see][p.2]{new,second}');
+ assert.equal(f.app.model.get().text, edited);
+ f.app.setMode('visual');
+ f.app.setMode('source');
+ assert.equal(f.app.model.get().text, edited);
+});
+
+test('visual edits synchronize before file switches and never leak another preamble or body', () => {
+ const a = tex('First paragraph.');
+ const b = '\\documentclass{report}\n\\begin{document}\nSecond paragraph.\n\\end{document}\n';
+ const f = fixture([textFile('a.tex', a), textFile('b.tex', b), textFile('refs.bib', '@book{keep, title={Book}}')]);
+ f.app.setMode('visual');
+ f.visual.querySelector('p').textContent = 'Edited first paragraph.';
+ f.app.switchFile('b.tex');
+ assert.equal(f.app.model.get('a.tex').text, a.replace('First paragraph.', 'Edited first paragraph.'));
+ assert.equal(f.app.model.get().text, b);
+ f.app.setMode('visual');
+ f.visual.querySelector('p').textContent = 'Edited second paragraph.';
+ f.app.switchFile('a.tex');
+ assert.match(f.ui.sourceArea.value, /documentclass\{article\}/);
+ assert.doesNotMatch(f.ui.sourceArea.value, /Second paragraph|documentclass\{report\}/);
+ assert.equal(f.app.model.get('b.tex').text, b.replace('Second paragraph.', 'Edited second paragraph.'));
+ assert.equal(f.app.model.get('refs.bib').text, '@book{keep, title={Book}}');
+});
+
+test('undo and redo operate on the selected file across source and visual edits', () => {
+ const f = fixture([textFile('main.tex', tex('Original.')), textFile('drawing.ps', '%!PS\nold')]);
+ f.ui.sourceArea.value = tex('Source edit.');
+ f.app.changed(false);
+ f.app.setMode('visual');
+ f.visual.querySelector('p').textContent = 'Visual edit.';
+ f.app.changed(false);
+ f.app.history('undo');
+ assert.equal(f.app.mode, 'visual');
+ assert.equal(f.visual.querySelector('p').textContent, 'Source edit.');
+ f.app.switchFile('drawing.ps');
+ f.ui.sourceArea.value = '%!PS\nnew';
+ f.app.changed(false);
+ f.app.history('undo');
+ assert.equal(f.ui.sourceArea.value, '%!PS\nold');
+ f.app.history('redo');
+ assert.equal(f.ui.sourceArea.value, '%!PS\nnew');
+ f.app.switchFile('main.tex');
+ f.app.history('redo');
+ assert.equal(f.app.model.get().text, tex('Visual edit.'));
+});
+
+test('project export includes non-active edits, literal source HTML and exact binary bytes', () => {
+ const binary = Uint8Array.from([0, 255, 254, 200, 13, 10]);
+ const base64 = Buffer.from(binary).toString('base64');
+ const f = fixture([textFile('main.tex', tex('Text.')), textFile('references.bib', '@book{old, title={Old}}'),
+ {path: 'images/a.bin', kind: 'binary', base64}]);
+ f.app.switchFile('references.bib');
+ f.ui.sourceArea.value = '@book{new, title={
- 公式工具
+ 公式工具
-
-
+
+
-
-
-
-
+
+
+
@@ -54,6 +55,17 @@
+ VisualLaTeX Studio
+
+
+
+
+
+
+ 本机编译
+
+
+