diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9bf8c2c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Vendored files must retain their registry bytes for manifest verification. +vendor/mathlive/** -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60dfb4d..38734a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,22 +18,28 @@ 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', 'visual-math.js', 'visual-references.js', 'visual-references-ui.js', 'vendor/mathlive/mathlive.min.js', 'compiler.mjs', 'pdf-preview.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, main.js, LICENSE, README.md, README.en.md -Destination $name + Copy-Item index.html, style.css, workspace.css, preview.css, visual-math.css, visual-references-ui.css, studio.css, studio-dialogs.css, main.js, workspace.js, project-model.js, project-archive.js, preview.js, visual-math.js, visual-references.js, visual-references-ui.js, compiler.mjs, pdf-preview.mjs, server.mjs, start.ps1, setup-windows.ps1, LICENSE, README.md, README.en.md -Destination $name + Copy-Item vendor -Destination $name -Recurse 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..3a828e4 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 visual-math.js visual-references.js visual-references-ui.js vendor/mathlive/mathlive.min.js compiler.mjs pdf-preview.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..5315545 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -29,17 +29,25 @@ jobs: - name: 验证待部署的同一提交 run: | - node --check main.js + for file in main.js workspace.js project-model.js project-archive.js preview.js visual-math.js visual-references.js visual-references-ui.js vendor/mathlive/mathlive.min.js compiler.mjs pdf-preview.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 visual-math.css visual-references-ui.css studio.css studio-dialogs.css main.js workspace.js project-model.js project-archive.js preview.js visual-math.js visual-references.js visual-references-ui.js LICENSE site/ + cp -R vendor 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 ad91ac8..81598e2 100644 --- a/README.en.md +++ b/README.en.md @@ -1,117 +1,126 @@ -
+# VisualLaTeX Studio -# VisualLaTeX +**Import a document, edit it visually, then export—with multiple files, PostScript and local PDF compilation.** -**Import · Tweak · Export — an offline LaTeX light editor that preserves original source** +The workspace centers the manuscript, with compact editing and compile controls, consistent icons, paper typography, and focused dialogs. Formula confirmation stays visible in the dialog footer. -[![Version](https://img.shields.io/badge/Version-v2.0.3-6f42c1)](https://github.com/Escap1ng/VisualLaTeX/releases) -[![License](https://img.shields.io/badge/License-Apache_2.0-0366d6)](./LICENSE) -[![CI](https://github.com/Escap1ng/VisualLaTeX/actions/workflows/ci.yml/badge.svg)](https://github.com/Escap1ng/VisualLaTeX/actions/workflows/ci.yml) -[![Online Demo](https://img.shields.io/badge/Online_Demo-GitHub_Pages-00b4d8)](https://Escap1ng.github.io/VisualLaTeX/) -[![Dependencies](https://img.shields.io/badge/Dependencies-none-959da5)](https://github.com/Escap1ng/VisualLaTeX/actions/workflows/build.yml) +![VisualLaTeX Studio](screenshots/studio-workspace.jpg) -[中文](README.md) · **English** +VisualLaTeX Studio continues VisualLaTeX's purpose of visually modifying existing LaTeX documents, adding full source access, multiple files, references, bibliography entries, ZIP projects and real compilation. It bundles MathLive 0.111.1 (MIT) and its fonts for formula editing. Offline editing needs no npm installation or build step. Compilation uses Node.js 22+ and a local typesetting toolchain. -
+**Complete LaTeX documents open in Visual editing by default.** Edit text and headings directly and use the formatting toolbar. Click a typeset formula to edit fractions, superscripts and subscripts in a graphical math field. References and labels appear as readable items that open an editor when clicked, without switching to source. Untouched source stays intact, and the current session remembers the selected editing mode for each file. ---- +Source editing remains available for preambles, custom macros and complex structures. Bibliography files, PostScript, TeX fragments and incomplete documents use source editing without forced visual conversion. PostScript supports source editing and rendering; arbitrary PS graphics cannot yet be modified as visual objects. -
+## Start -![VisualLaTeX editor screenshot](screenshots/editor.png) +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 -## Why This Exists +Install [Node.js 22 or newer](https://nodejs.org/en/download), unpack this project and run PowerShell in its directory: -It's the night before the deadline, and your advisor only asked you to "change a few sentences and reword a title" — yet the moment you open the `.tex` file, a wall of backslashes and braces stares back at you. Overleaf is great, but it is designed for people who write code. You just want a small, safe change. +```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. -**VisualLaTeX was built for exactly that:** +### macOS, Linux or an existing toolchain -| Scenario | Traditional Code Editor | VisualLaTeX | -|:---|:---|:---| -| Small-edit efficiency | Manually locate structures in source | WYSIWYG, click and edit | -| Risk of breakage | Easy to corrupt commands & environments | Complex structures locked verbatim | -| Learning curve | Requires LaTeX fluency | If you can use Word, you're set | +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 +``` -## Core Workflow +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. -**Import `.tex` → light visual edits → export `.tex`** +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. -Unedited source blocks retain their original comments, whitespace, and line endings. Edited regions are regenerated as LaTeX. Structures that cannot be safely converted stay in editable source blocks, fully offline. +### Editing and PDF output + +- Add individual files, open a project folder, or import a ZIP project. +- Complete LaTeX documents open in Visual editing: modify text and headings directly, use the formatting toolbar, or click formulas, references and labels to edit them. +- Switch to Source editing for preambles, custom macros and complex structures, then return to visual editing. 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. The PDF pane displays the result; modify content in the editor. 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. With Ghostscript installed, pages are displayed as images using Previous/Next controls, without requiring a browser PDF plugin. Without Ghostscript, the UI explains the limitation and falls back to native PDF display; the complete PDF remains exportable. + +Image preview supports the first 100 pages on a fixed 1200×1600 canvas with each complete page fitted proportionally. This does not change the exported PDF's page dimensions, vector content or page count. Images are generated on demand with a 20-second limit and can be retried after failure. Use the exported PDF to select or copy text. + +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. ## Features -| Feature | Details | -|:---|:---| -| One-click import | Open a local `.tex`; sections, paragraphs, lists, formulas, and images become visual elements | -| Split-pane view | Visual editor on the left, live LaTeX source preview on the right | -| Basic formatting | Three heading levels, bold, italic, center, ordered/unordered lists | -| Formula input | 30+ symbols; `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`; double-click visual formulas to edit | -| Verbatim blocks | Tables, bibliographies, code listings shown as-is, editable, never lost on export | -| Command protection | `\cite`, `\ref`, and other reference commands preserved verbatim | -| Image insertion | Enter a path to generate the `figure` float environment | -| One-click export | Copy source or download `.tex`, reusing the imported file name | -| Auto-save | Drafts cached together with the preamble; content survives page refresh | +| Feature | Support | +| --- | --- | +| Visual editing | Default for complete LaTeX documents; edit text, headings, formatting, lists, formulas, references and labels | +| Graphical math | Typeset formulas with click-to-edit fractions and scripts; inline/display math and equation, align and gather environments, including starred variants | +| Source files | TeX, LaTeX, STY, CLS, BIB, PS, EPS and UTF-8 text with custom extensions | +| Project management | Create, rename, delete, select main file and preserve relative paths | +| References | Readable, clickable cite/ref/label items; select project labels and BibTeX entries, edit keys and optional notes, and add bibliography entries | +| Find and replace | Current-file search, wrap-around, replace one/all and case sensitivity | +| History | Independent per-file undo/redo for source and visual changes | +| PostScript | PS/text EPS source, page/procedure outline, templates, UTF-8 and Latin-1; no arbitrary PS graphics object editing | +| 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 | -## Quick Start +Reference displays use section titles, label keys and BibTeX metadata such as authors, titles and years to identify targets. They do not claim to show compiled citation numbers or page numbers. The compiled PDF determines final numbering, bibliography style and typesetting. -**Zero install · zero dependencies · zero build** — clone and double-click to run offline: +The formula dialog provides graphical math input and fields for labels and explicit tags. Formula delimiters, environment names and unchanged source are preserved; labels and tags in multiline formulas remain associated with their rows. Use the dialog's advanced input or Source editing for custom macros and complex expressions unsupported by the renderer. Other unsupported visual structures remain editable raw TeX. New `main.tex` files have a document template; other TeX files start empty for use as fragments. -```bash -git clone https://github.com/Escap1ng/VisualLaTeX.git -``` +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. -Then double-click `index.html`. That's it. +## Limits -You can also use the [online demo](https://Escap1ng.github.io/VisualLaTeX/) (auto-deployed by GitHub Actions). +Projects allow 500 files and 50 MB of expanded content; ZIP input allows 100 MB. Encrypted, multi-disk and ZIP64 archives are unsupported. Browser storage quotas may be smaller; export ZIP backups for larger projects. Undo history is session-local. -## Project Structure +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. -``` -VisualLaTeX/ -├── index.html # Main page (layout, toolbar) -├── style.css # Global styles -├── main.js # Editor core, .tex parsing, DOM↔LaTeX conversion -├── tests/ # Zero-dependency smoke and import/export/editing regression checks -├── .github/ # GitHub Actions workflows (CI / release / Pages) -├── README.md # Docs (Chinese) -└── README.en.md # Docs (English) -``` +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. -## Continuous Integration +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. -Automation is powered by GitHub Actions: +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. -| Workflow | Trigger | Purpose | -|:---|:---|:---| -| CI | push or PR to `main` or `dev` | Syntax, asset integrity, and import/export/editing regression tests | -| Build & Release | push `v*` version tag | Package and release only after checks pass on that commit | -| Deploy to GitHub Pages | push to `main` | Deploy the [online demo](https://Escap1ng.github.io/VisualLaTeX/) only after checks pass on that commit | +## Development -Run locally with Node.js 22+; no npm packages are needed: +Node.js 22+, no npm installation: ```bash node --check main.js +node --check workspace.js +node --check visual-math.js +node --check visual-references.js +node --check visual-references-ui.js +node --check preview.js +node --check compiler.mjs +node --check server.mjs node tests/smoke.mjs node --test ``` -Regression checks cover HTML injection, source round trips, local edits, formulas, images, escaping, and draft recovery. DOM checks use a minimal model; they do not replace real browser interaction or TeX compilation tests. +Tests exercise actual functions and UI event handlers, compiler process orchestration, loopback service boundaries, cancellation and stale results. New regressions cover display-only formula/reference changes preserving source, edits confined to the selected reference command, math delimiters, environments and labels, legacy draft snapshot migration, visual reference interactions, and the local server's script/font allowlist. 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. -## Recommended Workflow +The pinned MathLive scripts, styles, fonts and license are in `vendor/mathlive/`. Its [manifest.json](vendor/mathlive/manifest.json) records the version, download source, package integrity and each file's SHA-256. These assets load locally without a CDN. -Import the `.tex` you want to touch up → make visual text and structure adjustments → export and download → drop it back into your project and compile. +See the [browser acceptance checklist](tests/browser-acceptance.md) for manual end-to-end validation. Actual engine validation in this iteration used Tectonic and Ghostscript; the XeLaTeX path has not yet been verified against a real distribution. -
-Known Limitations - -1. Built on `contenteditable`; complex editing behavior still needs validation in individual browsers -2. No visual table editing (tables are edited as source inside verbatim blocks) -3. Complex/nested environments, multiple images, custom list labels, and `equation` environments use editable source blocks; TeX macros are not expanded -4. This is a light editing tool; for large-scale rewrites, use a code editor - -
+`workspace.js` coordinates editing, `project-model.js` owns canonical source/history/indexes, and `project-archive.js` handles ZIP. `visual-math.js`/`.css` adapt MathLive for formula rendering and editing; `visual-references*.js`/`.css` provide reference semantics, readable displays and editing dialogs. `preview.js` manages PDF state and export; `compiler.mjs` and `server.mjs` run the local compilation service, while `pdf-preview.mjs` renders bounded page images. `setup-windows.ps1` and `start.ps1` prepare portable tools and start the service. The original `main.js` remains the visual LaTeX conversion core.
Version History @@ -125,18 +134,6 @@ Import the `.tex` you want to touch up → make visual text and structure adjust
-## Contributing - -Issue reports and Pull Requests are welcome via the [Issue tracker](https://github.com/Escap1ng/VisualLaTeX/issues). - ## License -[Apache License 2.0](./LICENSE) © Escap1ng - ---- - -
- -**If this tool saved you an hour, consider leaving a Star** - -
+Project code uses [Apache-2.0](LICENSE), based on the original project by Escap1ng. Bundled MathLive 0.111.1 uses the [MIT license](vendor/mathlive/LICENSE.txt); its notice is included in the source archive. diff --git a/README.md b/README.md index 6edadc3..d21fde6 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,155 @@ -
+# VisualLaTeX Studio -# VisualLaTeX +**导入文档,直接可视化修改,再导出——支持多文件、PostScript 与本机 PDF 编译。** -**导入 · 轻改 · 导出 —— 保留原文的离线 LaTeX 轻编辑器** +界面以文稿为中心:宽屏合并工具与编译操作,统一图标、纸面排版和编辑弹窗;公式确认按钮固定在弹窗底部。 -[![Version](https://img.shields.io/badge/Version-v2.0.3-6f42c1)](https://github.com/Escap1ng/VisualLaTeX/releases) -[![License](https://img.shields.io/badge/License-Apache_2.0-0366d6)](./LICENSE) -[![CI](https://github.com/Escap1ng/VisualLaTeX/actions/workflows/ci.yml/badge.svg)](https://github.com/Escap1ng/VisualLaTeX/actions/workflows/ci.yml) -[![Online Demo](https://img.shields.io/badge/Online_Demo-GitHub_Pages-00b4d8)](https://Escap1ng.github.io/VisualLaTeX/) -[![Dependencies](https://img.shields.io/badge/Dependencies-none-959da5)](https://github.com/Escap1ng/VisualLaTeX/actions/workflows/build.yml) +![VisualLaTeX Studio](screenshots/studio-workspace.jpg) -**中文** · [English](README.en.md) +VisualLaTeX Studio 延续可视化修改现有 LaTeX 文档的用途,并增加完整源码、多文件项目、参考文献、ZIP 管理及真实编译。公式编辑内置 MathLive 0.111.1(MIT)及其字体,无需 npm 安装或构建即可离线编辑;编译需要 Node.js 22+ 和相应工具链。 -
+**完整 LaTeX 文档默认进入「可视化编辑」。** 直接修改文字和标题,用工具栏调整格式;单击排版后的公式,使用图形输入框编辑分式、上下标等内容。引用和标签显示为可读条目,单击即可修改,无需切换源码。只修改需要改动的内容,保留其余源码;切换文件会记住当前会话中为各文件选择的编辑模式。 ---- +「源码编辑」随时可用,适合修改导言区、自定义宏和复杂结构。BibTeX、PostScript、章节片段及未完成的文档可直接编辑源码,并保存、导出;它们不会被强行转换成可视化内容。PostScript 当前支持源码编辑和渲染,不提供任意图形对象的可视化修改。 -
+## 开始使用 -![VisualLaTeX 编辑器界面](screenshots/editor.png) +默认项目包含 `main.tex`、`references.bib` 和 `drawing.ps`。只需编辑时可以双击 `index.html`;需要预览和 PDF 导出时,按以下方式启动本地服务。 -
+### Windows -## 为什么需要它 +安装 [Node.js 22 或更新版本](https://nodejs.org/en/download),解压源码包,在该目录打开 PowerShell: -论文截稿前夜,导师只让你"改几句话、换个标题"——可一打开 `.tex`,满屏反斜杠和花括号扑面而来。Overleaf 很强,但它为写代码的人设计;你只想安全地小改一下。 +```powershell +.\setup-windows.ps1 +.\start.ps1 +``` -**VisualLaTeX 就是为此而生:** +如果 PowerShell 的默认执行策略阻止脚本,可对这一次调用使用 `powershell -NoProfile -ExecutionPolicy Bypass -File .\setup-windows.ps1`,启动脚本同理;无需修改系统执行策略。 -| 场景 | 传统代码编辑器 | VisualLaTeX | -|:---|:---|:---| -| 小改动效率 | 在源码中手动定位结构 | 所见即所得,直接点改 | -| 误改风险 | 容易碰坏命令与环境 | 未编辑的源码块保留原文,复杂结构使用原文块 | -| 上手成本 | 需熟悉 LaTeX 语法 | 会用 Word 就会用 | +安装脚本下载固定版本的 Tectonic 0.17.0、Ghostscript 10.08.0 和解压工具,校验官方发布的 SHA-256 后放入当前目录的 `.tools`。它只解包安装器,不运行安装器、不写注册表、不覆盖项目文件。再次执行会保留已存在的工具;失败时下载和暂存文件会留在 `.tools` 便于检查。第三方引擎未包含在源码包中,各自许可保留在下载包内。 -## 核心工作流 +启动后在浏览器打开终端显示的地址,默认是 `http://127.0.0.1:4173`。更换端口可用 `.\start.ps1 -Port 4174`。终端需保持运行;按 Ctrl+C 退出。 -**导入 `.tex` → 可视化轻改 → 导出 `.tex`** +### macOS、Linux 或已有工具链 -未编辑的源码块按原文保留,包括注释、空白与换行;编辑过的区段重新生成 LaTeX。无法安全转换的结构以可编辑原文块保留,全程离线。 +安装 [Tectonic](https://tectonic-typesetting.github.io/book/latest/installation/index.html) 或 XeLaTeX,以及 [Ghostscript](https://ghostscript.com/releases/gsdnld.html),确保可执行文件在 PATH 中,然后运行: -## 功能一览 +```bash +node server.mjs +``` -| 功能 | 说明 | -|:---|:---| -| 一键导入 | 打开本地 `.tex`,章节、段落、列表、公式、图片自动转为可视化元素 | -| 双栏同屏 | 左侧可视化编辑,右侧 LaTeX 源码实时预览 | -| 基础排版 | 三级标题、加粗、斜体、居中、有序/无序列表 | -| 公式输入 | 30+ 符号快捷面板,支持 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`;双击已有可视化公式可修改 | -| 原样保留块 | 表格、参考文献、代码清单等复杂结构原文展示、可编辑、导出不丢失 | -| 命令保护 | `\cite`、`\ref` 等引用命令原样保留,绝不被转义破坏 | -| 图片插入 | 填写路径自动生成 `figure` 浮动环境 | -| 一键导出 | 复制源码或下载 `.tex`,自动沿用导入文件名 | -| 自动缓存 | 草稿连同导言区一起保存,刷新页面内容不丢失 | +也可用以下环境变量指定已有引擎的**可执行文件路径**:`VISUALLATEX_TECTONIC`、`VISUALLATEX_XELATEX`、`VISUALLATEX_GHOSTSCRIPT`;XeLaTeX 的文献工具可用 `VISUALLATEX_BIBTEX`、`VISUALLATEX_BIBER`。显式设置 XeLaTeX 可选择本机 TeX 发行版;不要同时指定两个 LaTeX 引擎。 -## 快速开始 +Tectonic 首次编译会按需联网下载宏包和字体支持文件,之后可复用缓存;源码在本机编译,不上传到第三方。Windows 启动脚本将缓存放在 `.tools/cache`;也可设置 `TECTONIC_CACHE_DIR`。已有本机 TeX 发行版按其自身方式管理宏包与字体。 -**零安装 · 零依赖 · 零构建**,克隆后双击即可离线运行: +### 编辑与生成 PDF -```bash -git clone https://github.com/Escap1ng/VisualLaTeX.git -``` +1. 用「添加文件」加入文件,或用「打开文件夹」「打开项目」导入目录/ZIP。 +2. 从左侧选择 `main.tex` 等完整 LaTeX 文档,在「可视化编辑」中修改文字、标题和格式;单击公式、引用或标签直接编辑。自定义宏和复杂结构可切换到「源码编辑」修改,再返回可视化继续写作。 +3. 用「下载文件」导出当前文件,或用「导出 ZIP」保存整个项目及资源。 +4. 选择编译目标:LaTeX 使用完整的主文档,PostScript 使用 `.ps` / `.eps` 文件,点击编译/渲染或按 Ctrl/Cmd+Enter。 +5. 在预览区检查生成的 PDF,使用「导出 PDF」保存结果。PDF 预览只显示排版结果,修改内容仍在编辑区完成。编译失败可查看日志,也可取消运行中的任务。 -然后双击 `index.html`。就这样。 +修改源码后,旧 PDF 会标记为过期;导出会先编译当前内容,避免将旧结果误当成最新版本。编译失败时保留上次预览,项目源码保持可编辑。本机安装 Ghostscript 后,预览以分页图片显示,不依赖浏览器自带 PDF 阅读器;可用「上一页」「下一页」切换。未安装 Ghostscript 时会明确提示,并尝试浏览器原生 PDF 预览,仍可导出完整 PDF。 -也可以直接使用[在线预览版](https://Escap1ng.github.io/VisualLaTeX/)(由 GitHub Actions 自动部署)。 +图片预览最多显示前 100 页,每页固定为 1200×1600 的适配画布(保留整页比例),不影响原 PDF 的页面尺寸、矢量内容和全部页数。每页按需生成,默认最多等待 20 秒;失败可以重试。图片预览不提供文字选择,请在导出的 PDF 中复制文本。 -## 项目结构 +`file://` 和 GitHub Pages 的静态版本只提供编辑,不执行编译。浏览器草稿按来源隔离;从旧离线页面切到本地服务前,请先导出 ZIP,再在本地服务中打开。 -``` -VisualLaTeX/ -├── index.html # 主页面(布局、工具栏) -├── style.css # 全局样式 -├── main.js # 编辑器核心、.tex 解析、DOM↔LaTeX 转换 -├── tests/ # 零依赖冒烟检查与导入/导出/编辑回归测试 -├── .github/ # GitHub Actions 工作流(CI / 自动发布 / Pages 部署) -├── README.md # 项目说明(中文) -└── README.en.md # 项目说明(English) -``` +打开文件夹或项目包会在确认后替换当前项目;添加文件会保留已有文件,并自动为重名文件另起名称。源码中已有的 `\input`、图片路径和引用键不会因重命名文件而自动重写。 + +## 编辑能力 + +| 功能 | 支持范围 | +| --- | --- | +| 可视化修改 | 完整 LaTeX 文档默认可视化编辑;直接修改文字、标题、格式、列表、公式、引用及标签 | +| 图形公式编辑 | 公式排版显示;单击编辑分式、上下标等,支持行内/行间公式及 equation、align、gather 的普通和星号环境 | +| 完整源码 | `.tex`、`.latex`、`.sty`、`.cls`、`.bib`、`.ps`、`.eps` 及自定义扩展名的 UTF-8 文本文件 | +| 多文件工作区 | 新建、重命名、删除、设定主文件;保留相对目录结构 | +| 可视化/源码 | 切换时同步内容;记住各文件在当前会话中选择的模式;不完整 TeX、片段、BibTeX 和 PostScript 使用源码模式 | +| 引用与文献 | `\cite` / `\ref` / `\label` 显示为可读条目并可单击编辑;选择项目标签及 BibTeX 条目,修改引用键和可选注,新增文献条目 | +| 查找替换 | 当前文件查找、循环查找、单次替换、全部替换、区分大小写 | +| 撤销重做 | 每个文件独立历史,支持源码和可视化编辑;同一输入过程合并成一步 | +| PostScript | `.ps` / 文本 `.eps` 源码编辑、页面/过程导航、示例文件、UTF-8 与 Latin-1 编码;暂不支持任意 PS 图形的可视化修改 | +| 图片等资源 | 二进制文件按原始字节保存;二进制 EPS 保留为资源,可单独下载 | +| 项目 ZIP | 导入 stored/deflate ZIP,导出标准 ZIP;包含未打开文件的编辑及原始资源 | +| 本机草稿 | 整个项目单次保存;失败时显示状态;兼容旧版单文档草稿迁移 | +| LaTeX 编译 | Tectonic 或 XeLaTeX,带项目文件与资源、引用及参考文献处理 | +| PostScript 渲染 | Ghostscript 将 PS/EPS 转为 PDF;EPS 按 BoundingBox 裁切 | +| PDF 预览与导出 | 编译目标选择、取消、错误日志、旧结果标记及当前版本导出 | + +可视化引用使用项目中的章节标题、标签键和 BibTeX 作者、标题、年份等元数据帮助识别目标,不将这些提示当作编译后的引用编号或页码。最终引用编号、文献样式和公式排版以编译生成的 PDF 为准。 + +公式编辑窗口提供图形数学输入和标签/显示编号字段,保留公式分隔符、环境名及未编辑的原文。多行公式的标签和编号按所属行保留。自定义宏或当前渲染器不支持的复杂表达式仍可使用窗口中的高级输入或源码编辑。LaTeX 章节、基础格式、列表和简单图片保留可视化支持;其他复杂结构以可编辑原文块呈现。 + +新建 `main.tex` 会生成完整文档;其他 `.tex` 文件默认留空,适合 `\input` 的章节片段。新建 `.ps` / `.eps` 会提供对应模板。 + +## 快捷键 -## 持续集成 +| 快捷键 | 操作 | +| --- | --- | +| Ctrl/Cmd + S | 保存本机项目草稿 | +| Ctrl/Cmd + F | 当前文件查找替换 | +| Ctrl/Cmd + Z | 撤销 | +| Ctrl/Cmd + Shift + Z / Ctrl + Y | 重做 | +| Ctrl/Cmd + Enter | 编译或渲染当前目标 | +| Tab | 在源码中插入两个空格 | -项目使用 GitHub Actions 实现自动化检查与发布: +底栏的编码按钮可显式切换 UTF-8 / Latin-1。Latin-1 不能表达的字符不会被静默替换;需要输入这些字符时先切换编码。 -| 工作流 | 触发时机 | 作用 | -|:---|:---|:---| -| CI | 推送 `main`、`dev` / 针对这两个分支的 PR | JS 语法、资源完整性及导入/导出/编辑回归测试 | -| Build & Release | 推送 `v*` 版本标签 | 同一提交检查通过后打包并上传 GitHub Release | -| Deploy to GitHub Pages | 推送 `main` | 同一提交检查通过后部署[在线预览版](https://Escap1ng.github.io/VisualLaTeX/) | +## 数据与边界 -本地验证(Node.js 22+,无需安装 npm 包): +- 项目最多 500 个文件、展开后 50 MB;ZIP 包最多 100 MB。加密、分卷和 ZIP64 包暂不支持。 +- 浏览器自动保存容量由浏览器决定,可能小于项目导入上限。大项目请使用「导出 ZIP」备份;撤销历史仅保留在当前会话。 +- UTF-8 BOM、原始换行和未编辑的 LaTeX 源码块会被保留。可视化改动的区段会重新生成 LaTeX。 +- ZIP 中的项目名称、主文件和当前文件记录在 ZIP 注释中,不占用或覆盖用户文件名;第三方重新打包可能移除这些设置,但源码和资源仍可导入。 +- 编译是显式操作,默认最长 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 语法分析;正式排版与诊断以编译引擎结果为准。 + +## 开发与验证 + +Node.js 22+,无需安装 npm 包: ```bash node --check main.js +node --check workspace.js +node --check visual-math.js +node --check visual-references.js +node --check visual-references-ui.js +node --check preview.js +node --check compiler.mjs +node --check server.mjs node tests/smoke.mjs node --test ``` -回归测试覆盖 HTML 注入防护、源码往返、局部编辑、公式、图片、转义字符及草稿恢复。DOM 测试使用最小模型,不替代真实浏览器交互或 TeX 编译验证。 +测试覆盖源码往返、HTML 注入、草稿迁移、多文件历史、引用索引、PostScript/资源字节、ZIP 完整性、编译进程与本地服务边界、取消/过期结果及实际界面事件。新增回归覆盖公式/引用展示变化不改写原文、只修改选中的引用命令、数学分隔符与环境及标签保留、旧草稿快照迁移、可视化引用交互,以及本机服务的公式脚本/字体白名单。常规测试通过模拟引擎验证流程,无需安装工具链;DOM 测试使用最小模型,不代表真实浏览器布局或实际引擎排版验证。 -## 推荐工作流 +MathLive 的本地脚本、样式、字体和许可位于 `vendor/mathlive/`;[manifest.json](vendor/mathlive/manifest.json) 记录固定版本、下载来源、包完整性及每个文件的 SHA-256。编辑器从本地资源加载它们,不依赖 CDN。 -导入要修改的 `.tex` → 可视化完成文字与结构调整 → 导出下载 → 放回原项目编译。 - -
-已知限制 +真实浏览器验收步骤见 [浏览器验收清单](tests/browser-acceptance.md)。本次真实工具链验证使用 Tectonic 和 Ghostscript;XeLaTeX 分支尚未在真实发行版中验证。 -1. 基于 `contenteditable`,不同浏览器的复杂编辑行为仍需实际验证 -2. 不支持可视化表格编辑(表格以原样保留块形式编辑源码) -3. 复杂/嵌套环境、多图、带自定义标签的列表及 `equation` 环境以原文块编辑;不执行 TeX 宏展开 -4. 定位是轻量修改工具,大规模重写建议直接使用代码编辑器 +CI 检查 `main` / `dev` 的推送与 PR;部署和发布都在检查通过后执行。 -
+``` +index.html / workspace.css 工作台界面 +workspace.js 文件、编辑模式、查找替换与导入导出协调 +project-model.js 源码、项目状态、历史、引用索引 +project-archive.js 无依赖 ZIP 读写 +main.js / style.css LaTeX 解析、转换及可视化编辑核心 +visual-math.js / .css 公式排版、图形输入及公式标签编辑 +visual-references*.js/.css 引用语义、可读展示和编辑窗口 +vendor/mathlive/ MathLive 固定版本、字体、许可及校验清单 +preview.js / preview.css 编译状态、PDF 预览和导出界面 +compiler.mjs / server.mjs 本机编译工具与回环 HTTP 服务 +pdf-preview.mjs 有界的本机 PDF 分页图片渲染 +setup-windows.ps1 / start.ps1 Windows 便携工具安装与启动 +tests/ 零依赖回归测试 +```
版本历史 @@ -125,18 +163,6 @@ node --test
-## 参与贡献 - -欢迎提交 [Issue](https://github.com/Escap1ng/VisualLaTeX/issues) 或 Pull Request。 - -## 开源协议 - -[Apache License 2.0](./LICENSE) © Escap1ng - ---- - -
- -**如果这个工具帮你省下了一小时,欢迎点一个 Star** +## 许可 -
+项目代码沿用 [Apache-2.0](LICENSE)。VisualLaTeX 由 Escap1ng 开发,Studio 在其基础上扩展。内置 MathLive 0.111.1 使用 [MIT 许可](vendor/mathlive/LICENSE.txt),随源码包保留其许可声明。 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 d25ff0c..0e3fe35 100644 --- a/index.html +++ b/index.html @@ -3,135 +3,277 @@ - VisualLaTeX - 离线 LaTeX 轻编辑器 + VisualLaTeX Studio · 可视化 LaTeX 编辑与排版 + + + + + + - +
- -
-

VisualLaTeX · 离线 LaTeX 轻编辑器

-

导入 .tex 文件,可视化轻量修改,右侧实时生成 LaTeX 代码,全程离线运行

-
- - -
- - - - -
+
+
+ +

VisualLaTeX Studio

+ TeX PS +
+
+ + +
+
+ + + +
+
- - - + -
+
+ + + + + + + 本机编译 + +
- - +
+ -
+
+
+
+ LaTeX + main.tex +
+
+ + +
+
- - - + -
+ - - - +
+
点击文字、公式或引用,直接编辑
+
+
-
+ - - -
+ + - - + - -
- -
-
可视化编辑区
-
-

欢迎使用 VisualLaTeX

-

这是一个纯前端、零依赖、完全离线的 LaTeX 轻编辑器,适合对已有论文做快速小修改。

- -

三步上手

-
    -
  1. 点击工具栏的「导入 .tex」,选择本地的 LaTeX 文件
  2. -
  3. 在左侧修改文字、标题与列表,双击已有公式可打开编辑
  4. -
  5. 点击「下载文件」导出修改后的 .tex,或复制代码到剪贴板
  6. -
- -

公式示例

-

行内公式示例:$E=mc^2$

- -
$$\int_{a}^{b} f(x) \, dx = F(b) - F(a)$$
- -

小提示

-

表格、参考文献、代码清单等复杂结构会以灰色「原样保留块」展示,可以直接编辑其中文本,导出时原样输出,不会丢失内容。修改内容会自动保存为草稿。

+
- - -
-
LaTeX 代码预览
-
-
+

这里只显示排版结果;修改内容请返回编辑区。

+

正在检测本机编译服务…

+ + + + +
+
查看排版结果选择 LaTeX、PS 或 EPS 文件后点击「编译预览」。修改源码后可重新编译,也可直接导出最新 PDF。
+ + +
+ +
+ 编译日志 +
编译后将在这里显示引擎日志。
+
+
- -
- - -
+
+ 就绪 + 项目自动保存 +
+
- -
-
-
- 就绪 -
-
- 草稿自动保存 -
-
+ + + + + + -