diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0a04fb5..60dfb4d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -13,6 +13,19 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: JS 语法检查
+ run: node --check main.js
+
+ - name: 冒烟检查
+ run: node tests/smoke.mjs
+
+ - name: 导入、导出与编辑回归测试
+ run: node --test
+
# 纯静态项目无运行时依赖,无需安装
- name: 打包项目为 zip
shell: pwsh
@@ -22,6 +35,7 @@ jobs:
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 screenshots -Destination $name -Recurse
+ Copy-Item tests -Destination $name -Recurse
Compress-Archive -Path $name -DestinationPath "$name.zip"
- name: 发布到 GitHub Release
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e4c90ec..9a10ed5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,15 +2,18 @@ name: CI
on:
push:
- branches: [main]
+ branches: [main, dev]
pull_request:
- branches: [main]
+ branches: [main, dev]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
# JS 语法检查(等效于 compileall)
- name: JS 语法检查
@@ -19,3 +22,6 @@ jobs:
# 无头冒烟检查:资源完整、标签配对、DOM id 一致性
- name: 冒烟检查
run: node tests/smoke.mjs
+
+ - name: 导入、导出与编辑回归测试
+ run: node --test
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index e279418..fd61209 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -23,6 +23,16 @@ jobs:
- name: 检出代码
uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: 验证待部署的同一提交
+ run: |
+ node --check main.js
+ node tests/smoke.mjs
+ node --test
+
- name: 配置 Pages
uses: actions/configure-pages@v5
diff --git a/README.en.md b/README.en.md
index 8d76600..fa88ed8 100644
--- a/README.en.md
+++ b/README.en.md
@@ -2,7 +2,7 @@
# VisualLaTeX
-**Import · Tweak · Export — an offline LaTeX light editor that never loses a character**
+**Import · Tweak · Export — an offline LaTeX light editor that preserves original source**
[](https://github.com/Escap1ng/VisualLaTeX/releases)
[](./LICENSE)
@@ -38,7 +38,7 @@ It's the night before the deadline, and your advisor only asked you to "change a
**Import `.tex` → light visual edits → export `.tex`**
-Changes only happen where you make them; everything else is preserved character-for-character, fully offline.
+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.
## Features
@@ -47,7 +47,7 @@ Changes only happen where you make them; everything else is preserved character-
| 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+ symbol quick palette, inline `$...$` and display `$$...$$` math |
+| 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 |
@@ -73,7 +73,7 @@ VisualLaTeX/
├── index.html # Main page (layout, toolbar)
├── style.css # Global styles
├── main.js # Editor core, .tex parsing, DOM↔LaTeX conversion
-├── tests/ # Zero-dependency smoke checks (used by CI)
+├── 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)
@@ -85,9 +85,19 @@ Automation is powered by GitHub Actions:
| Workflow | Trigger | Purpose |
|:---|:---|:---|
-| CI | push to `main` / PR to `main` | JS syntax check + zero-dependency smoke checks (assets, tag pairing, DOM id consistency) |
-| Build & Release | push `v*` version tag | Package `VisualLaTeX-vX.Y.Z.zip` and auto-upload to GitHub Release |
-| Deploy to GitHub Pages | push to `main` | Auto-deploy the [online demo](https://Escap1ng.github.io/VisualLaTeX/) |
+| 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 |
+
+Run locally with Node.js 22+; no npm packages are needed:
+
+```bash
+node --check main.js
+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.
## Recommended Workflow
@@ -96,9 +106,9 @@ Import the `.tex` you want to touch up → make visual text and structure adjust
Known Limitations
-1. Built on `contenteditable` for simplicity; complex nested layouts may parse incorrectly
+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. Edge cases such as nested same-name environments (e.g., itemize inside itemize) may not parse perfectly
+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
diff --git a/README.md b/README.md
index e3212a5..e2e74b3 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
# VisualLaTeX
-**导入 · 轻改 · 导出 —— 一个字符都不丢的离线 LaTeX 轻编辑器**
+**导入 · 轻改 · 导出 —— 保留原文的离线 LaTeX 轻编辑器**
[](https://github.com/Escap1ng/VisualLaTeX/releases)
[](./LICENSE)
@@ -31,14 +31,14 @@
| 场景 | 传统代码编辑器 | VisualLaTeX |
|:---|:---|:---|
| 小改动效率 | 在源码中手动定位结构 | 所见即所得,直接点改 |
-| 误改风险 | 容易碰坏命令与环境 | 复杂结构原样锁存,零风险 |
+| 误改风险 | 容易碰坏命令与环境 | 未编辑的源码块保留原文,复杂结构使用原文块 |
| 上手成本 | 需熟悉 LaTeX 语法 | 会用 Word 就会用 |
## 核心工作流
**导入 `.tex` → 可视化轻改 → 导出 `.tex`**
-修改只发生在你改动的地方,其余内容逐字符原样保留,全程离线。
+未编辑的源码块按原文保留,包括注释、空白与换行;编辑过的区段重新生成 LaTeX。无法安全转换的结构以可编辑原文块保留,全程离线。
## 功能一览
@@ -47,7 +47,7 @@
| 一键导入 | 打开本地 `.tex`,章节、段落、列表、公式、图片自动转为可视化元素 |
| 双栏同屏 | 左侧可视化编辑,右侧 LaTeX 源码实时预览 |
| 基础排版 | 三级标题、加粗、斜体、居中、有序/无序列表 |
-| 公式输入 | 30+ 符号快捷面板,支持行内 `$...$` 与行间 `$$...$$` |
+| 公式输入 | 30+ 符号快捷面板,支持 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`;双击已有可视化公式可修改 |
| 原样保留块 | 表格、参考文献、代码清单等复杂结构原文展示、可编辑、导出不丢失 |
| 命令保护 | `\cite`、`\ref` 等引用命令原样保留,绝不被转义破坏 |
| 图片插入 | 填写路径自动生成 `figure` 浮动环境 |
@@ -73,7 +73,7 @@ VisualLaTeX/
├── index.html # 主页面(布局、工具栏)
├── style.css # 全局样式
├── main.js # 编辑器核心、.tex 解析、DOM↔LaTeX 转换
-├── tests/ # 零依赖冒烟检查(CI 使用)
+├── tests/ # 零依赖冒烟检查与导入/导出/编辑回归测试
├── .github/ # GitHub Actions 工作流(CI / 自动发布 / Pages 部署)
├── README.md # 项目说明(中文)
└── README.en.md # 项目说明(English)
@@ -85,9 +85,19 @@ VisualLaTeX/
| 工作流 | 触发时机 | 作用 |
|:---|:---|:---|
-| CI | 推送 `main` / 针对 `main` 的 PR | JS 语法检查 + 零依赖冒烟检查(资源完整、标签配对、DOM id 一致性) |
-| Build & Release | 推送 `v*` 版本标签 | 打包 `VisualLaTeX-vX.Y.Z.zip` 并自动上传至 GitHub Release |
-| Deploy to GitHub Pages | 推送 `main` | 自动部署[在线预览版](https://Escap1ng.github.io/VisualLaTeX/) |
+| CI | 推送 `main`、`dev` / 针对这两个分支的 PR | JS 语法、资源完整性及导入/导出/编辑回归测试 |
+| Build & Release | 推送 `v*` 版本标签 | 同一提交检查通过后打包并上传 GitHub Release |
+| Deploy to GitHub Pages | 推送 `main` | 同一提交检查通过后部署[在线预览版](https://Escap1ng.github.io/VisualLaTeX/) |
+
+本地验证(Node.js 22+,无需安装 npm 包):
+
+```bash
+node --check main.js
+node tests/smoke.mjs
+node --test
+```
+
+回归测试覆盖 HTML 注入防护、源码往返、局部编辑、公式、图片、转义字符及草稿恢复。DOM 测试使用最小模型,不替代真实浏览器交互或 TeX 编译验证。
## 推荐工作流
@@ -96,9 +106,9 @@ VisualLaTeX/
已知限制
-1. 基于 `contenteditable` 简易富文本实现,复杂嵌套排版容易解析出错
+1. 基于 `contenteditable`,不同浏览器的复杂编辑行为仍需实际验证
2. 不支持可视化表格编辑(表格以原样保留块形式编辑源码)
-3. 嵌套同名环境(如 itemize 套 itemize)等极端结构可能解析不完美
+3. 复杂/嵌套环境、多图、带自定义标签的列表及 `equation` 环境以原文块编辑;不执行 TeX 宏展开
4. 定位是轻量修改工具,大规模重写建议直接使用代码编辑器
diff --git a/index.html b/index.html
index 72ebf1a..d25ff0c 100644
--- a/index.html
+++ b/index.html
@@ -64,7 +64,7 @@
欢迎使用 VisualLaTeX
三步上手
- 点击工具栏的「导入 .tex」,选择本地的 LaTeX 文件
- - 在左侧像使用 Word 一样修改文字、标题、列表与公式
+ - 在左侧修改文字、标题与列表,双击已有公式可打开编辑
- 点击「下载文件」导出修改后的 .tex,或复制代码到剪贴板
diff --git a/main.js b/main.js
index 7bcf3da..53812cb 100644
--- a/main.js
+++ b/main.js
@@ -11,8 +11,12 @@ let statusText = null;
let debounceTimer = null;
let texPreamble = null; // 导入文件的导言区(原样保留,导出时复用)
let texTail = ''; // \end{document} 之后的尾部内容(原样保留)
+let texDocumentOpen = '\\begin{document}';
+let texDocumentClose = '\\end{document}';
let importedFileName = null; // 导入的文件名(用于导出命名)
let savedSelection = null; // 保存编辑器选区(用于公式弹窗等场景)
+let draftRecoveryFailed = false;
+let suppressDraftSave = false;
// 初始化
document.addEventListener('DOMContentLoaded', function() {
@@ -41,6 +45,12 @@ document.addEventListener('DOMContentLoaded', function() {
}, 300);
});
+ // 在刷新/切换页面前补存尚未经过防抖计时器的输入。
+ window.addEventListener('pagehide', flushDraft);
+ document.addEventListener('visibilitychange', function() {
+ if (document.visibilityState === 'hidden') flushDraft();
+ });
+
// 初始更新预览
updatePreview();
@@ -95,12 +105,19 @@ function initToolbar() {
}
const reader = new FileReader();
reader.onload = function(e) {
- applyImportedTex(e.target.result, file.name);
+ let text;
+ try {
+ text = decodeTexFile(e.target.result);
+ } catch (error) {
+ alert('导入失败:请使用 UTF-8 编码且不含空字节的文本文件;当前内容未被替换。');
+ return;
+ }
+ applyImportedTex(text, file.name);
};
reader.onerror = function() {
alert('读取文件失败,请重试');
};
- reader.readAsText(file, 'UTF-8');
+ reader.readAsArrayBuffer(file);
});
// 开始和重新加载按钮
@@ -208,19 +225,22 @@ function insertBlockFormula() {
// 插入图片
function insertImage() {
+ saveEditorSelection();
const imagePath = prompt('请输入图片路径(LaTeX 中使用的路径):', 'example-image.png');
+ if (!imagePath) return;
const caption = prompt('请输入图片标题:', '图片标题');
+ if (caption === null) return;
const label = prompt('请输入图片标签(用于引用):', 'fig:example');
+ if (label === null) return;
- if (imagePath) {
- const img = document.createElement('img');
- img.src = imagePath;
- img.alt = caption;
- img.setAttribute('data-caption', caption || '');
- img.setAttribute('data-label', label || '');
- img.setAttribute('data-width', '0.6');
+ const img = document.createElement('img');
+ img.src = imagePath;
+ img.alt = caption;
+ img.setAttribute('data-caption', caption);
+ img.setAttribute('data-label', label);
+ img.setAttribute('data-width', '0.6');
- insertNodeAtCursor(img);
+ if (insertNodeAtCursor(img)) {
updatePreview();
saveDraft();
}
@@ -229,42 +249,117 @@ function insertImage() {
// 在光标处插入节点
function insertNodeAtCursor(node) {
const sel = window.getSelection();
- if (sel.getRangeAt && sel.rangeCount) {
- const range = sel.getRangeAt(0);
- range.deleteContents();
- range.insertNode(node);
-
- const space = document.createTextNode(' ');
- range.setStartAfter(node);
- range.setEndAfter(node);
- range.insertNode(space);
-
- range.setStartAfter(space);
+ if (!sel.rangeCount || !editorArea.contains(sel.getRangeAt(0).commonAncestorContainer)) {
+ restoreEditorSelection();
+ }
+ if (!sel.rangeCount) return false;
+
+ const range = sel.getRangeAt(0);
+ if (!editorArea.contains(range.commonAncestorContainer)) return false;
+ // 富文本节点应放在原文块外,避免插入的图片/公式被当作源码文本导出。
+ const container = range.commonAncestorContainer;
+ const element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement;
+ const rawBlock = element && element.closest ? element.closest('.raw-tex') : null;
+ if (rawBlock && editorArea.contains(rawBlock)) {
+ range.setStartAfter(rawBlock);
range.collapse(true);
- sel.removeAllRanges();
- sel.addRange(range);
}
+ range.deleteContents();
+ range.insertNode(node);
+
+ const space = document.createTextNode(' ');
+ range.setStartAfter(node);
+ range.setEndAfter(node);
+ range.insertNode(space);
+
+ range.setStartAfter(space);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ savedSelection = range.cloneRange();
+ return true;
}
// ========== .tex 导入 ==========
+function decodeTexFile(bytes) {
+ // 保留 UTF-8 BOM;遇到无效编码时拒绝导入,而不是用替换字符覆盖原文。
+ const text = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(bytes);
+ if (text.includes('\0')) throw new Error('文件包含空字节');
+ return text;
+}
+
// 应用导入的 .tex 内容
function applyImportedTex(tex, fileName) {
- const docMatch = tex.match(/\\begin\{document\}([\s\S]*?)\\end\{document\}([\s\S]*)$/);
- if (!docMatch) {
+ const parts = splitTexDocument(tex);
+ if (!parts) {
alert('导入失败:未找到 \\begin{document}...\\end{document} 结构');
return;
}
- const preambleMatch = tex.match(/^([\s\S]*?)\\begin\{document\}/);
- texPreamble = preambleMatch ? preambleMatch[1].trim() : '';
- texTail = docMatch[2].trim();
+ texPreamble = parts.preamble;
+ texTail = parts.tail;
+ texDocumentOpen = parts.open;
+ texDocumentClose = parts.close;
importedFileName = fileName || null;
-
- editorArea.innerHTML = parseTexToHtml(docMatch[1]);
+ draftRecoveryFailed = false;
+ suppressDraftSave = false;
+
+ editorArea.innerHTML = parseTexToHtml(parts.body);
+ // 从内向外记录浏览器规范化后的节点,包含格式/公式属性变化。
+ const importedNodes = Array.from(editorArea.querySelectorAll('[data-tex-source]'));
+ importedNodes.reverse().forEach(function(node) {
+ node.setAttribute('data-tex-snapshot', getTexNodeSnapshot(node));
+ });
updatePreview();
- saveDraft();
- updateStatus('已导入 ' + (fileName || '.tex 文件'));
+ if (saveDraft()) updateStatus('已导入 ' + (fileName || '.tex 文件'));
+}
+
+// 查找真正的文档边界,跳过注释、宏参数和逐字环境中的同名字符串。
+function splitTexDocument(tex) {
+ let depth = 0;
+ let start = null;
+ for (let i = 0; i < tex.length;) {
+ if (tex[i] === '%') {
+ const end = tex.indexOf('\n', i);
+ i = end < 0 ? tex.length : end + 1;
+ continue;
+ }
+ if (tex[i] === '\\') {
+ const verb = tex.slice(i).match(/^\\verb\*?([^A-Za-z\s])/);
+ if (verb) {
+ const end = tex.indexOf(verb[1], i + verb[0].length);
+ i = end < 0 ? tex.length : end + 1;
+ continue;
+ }
+ const env = tex.slice(i).match(/^\\(begin|end)\s*\{([^{}]+)\}/);
+ if (env) {
+ if (env[1] === 'begin' && /^(?:verbatim\*?|Verbatim|lstlisting|minted|comment)$/.test(env[2])) {
+ const close = '\\end{' + env[2] + '}';
+ const end = tex.indexOf(close, i + env[0].length);
+ i = end < 0 ? tex.length : end + close.length;
+ continue;
+ }
+ if (depth === 0 && env[2] === 'document') {
+ if (env[1] === 'begin' && !start) {
+ start = {index: i, end: i + env[0].length, token: env[0]};
+ } else if (env[1] === 'end' && start) {
+ return {preamble: tex.slice(0, start.index), open: start.token,
+ body: tex.slice(start.end, i), close: env[0], tail: tex.slice(i + env[0].length)};
+ }
+ }
+ i += env[0].length;
+ continue;
+ }
+ const command = readTexCommand(tex, i);
+ i = command ? command.end : i + 1;
+ continue;
+ }
+ if (tex[i] === '{') depth++;
+ else if (tex[i] === '}') depth = Math.max(0, depth - 1);
+ i++;
+ }
+ return null;
}
// 开始新文档(清空编辑器)
@@ -274,17 +369,23 @@ function startNewDocument() {
editorArea.innerHTML = '新文档
在此输入内容...
';
texPreamble = null;
texTail = '';
+ texDocumentOpen = '\\begin{document}';
+ texDocumentClose = '\\end{document}';
importedFileName = null;
+ draftRecoveryFailed = false;
+ suppressDraftSave = false;
updatePreview();
- saveDraft();
- updateStatus('已开始新文档');
+ if (saveDraft()) updateStatus('已开始新文档');
}
// 重新加载并删除草稿
function reloadAndClearDraft() {
if (!confirm('确定要删除草稿并重新加载吗?所有未保存的内容将丢失。')) return;
+ // pagehide 的补存不能在清除草稿后把旧内容重新写回。
+ suppressDraftSave = true;
try {
+ localStorage.removeItem('visuellatex_document');
localStorage.removeItem('visuellatex_draft');
localStorage.removeItem('visuellatex_draft_time');
localStorage.removeItem('visuellatex_preamble');
@@ -295,6 +396,9 @@ function reloadAndClearDraft() {
localStorage.removeItem('modelpaper_draft_time');
} catch (e) {
console.error('清除草稿失败:', e);
+ suppressDraftSave = false;
+ updateStatus('清除草稿失败,请重试');
+ return;
}
location.reload();
@@ -315,145 +419,361 @@ function escapeAttr(text) {
return escapeForHtml(text).replace(/"/g, '"');
}
-// 解析 LaTeX 正文为 HTML
-function parseTexToHtml(tex) {
- let html = '';
-
- // 辅助:清理 LaTeX 文本为 HTML(保留行内公式与行内命令)
- function cleanLatexText(text) {
- // 1) 先提取行内公式,避免被其他替换影响
- const formulas = [];
- text = text.replace(/\$([^$]+)\$/g, function(match, formula) {
- const f = formula.trim();
- formulas.push(f);
- return '\x00F' + (formulas.length - 1) + '\x00';
- });
- // 2) 转义普通文本中的 HTML 特殊字符(公式已提取,不受影响)
- text = escapeForHtml(text);
- // 3) 处理文本格式命令
- text = text
- .replace(/\\textbf\{([^}]*)\}/g, '$1')
- .replace(/\\textit\{([^}]*)\}/g, '$1')
- .replace(/\\emph\{([^}]*)\}/g, '$1')
- .replace(/\\\\/g, '
');
- // 4) 其余行内命令(\cite、\ref、\eqref、\footnote 等)原样保留为只读 span
- // (文本已转义,此处无需再次转义,避免 & 变成 &)
- text = text.replace(/\\[a-zA-Z]+(?:\s*\[[^\]\n]*\])?(?:\s*\{[^{}\n]*\})*/g, function(cmd) {
- return '' + cmd + '';
- });
- // 5) 还原行内公式为只读 span
- text = text.replace(/\x00F(\d+)\x00/g, function(match, idx) {
- const f = formulas[parseInt(idx, 10)];
- return '$' + escapeForHtml(f) + '$';
- });
- return text.trim();
+function readTexVerbatim(text, start) {
+ const match = /^\\(verb\*?|lstinline\*?)(?![A-Za-z@])/.exec(text.slice(start));
+ if (!match) return null;
+ let delimiterAt = start + match[0].length;
+ if (match[1].startsWith('lstinline') && text[delimiterAt] === '[') {
+ const options = readTexGroup(text, delimiterAt, '[', ']');
+ if (!options) return null;
+ delimiterAt = options.end;
}
+ const delimiter = text[delimiterAt];
+ if (!delimiter || delimiter === '\r' || delimiter === '\n' || delimiter === '{') return null;
+ const newline = text.slice(delimiterAt + 1).search(/[\r\n]/);
+ const lineEnd = newline < 0 ? text.length : delimiterAt + 1 + newline;
+ const closing = text.indexOf(delimiter, delimiterAt + 1);
+ const end = closing >= 0 && closing < lineEnd ? closing + 1 : lineEnd;
+ return {name: match[1], start, end, args: [], source: text.slice(start, end)};
+}
- // 去除注释(不处理 \% 转义)
- tex = tex.replace(/(^|[^\\])%.*/g, '$1');
-
- let body = tex;
-
- // equation 环境 -> 公式块
- body = body.replace(/\\begin\{equation\}([\s\S]*?)\\end\{equation\}/g, function(match, content) {
- const formula = content.trim();
- return '\n$$' + escapeForHtml(formula) + '$$
\n';
- });
+// Read balanced TeX arguments instead of stopping at the first closing brace.
+function readTexGroup(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] === '\\') {
+ const literal = readTexVerbatim(text, i);
+ i = literal ? literal.end - 1 : i + 1;
+ continue;
+ }
+ if (text[i] === '%') {
+ const newline = text.indexOf('\n', i);
+ if (newline === -1) return null;
+ i = newline;
+ continue;
+ }
+ if (open === '[') {
+ if (text[i] === '{') braces++;
+ if (text[i] === '}') braces--;
+ if (braces > 0) continue;
+ }
+ if (text[i] === open) depth++;
+ if (text[i] === close && --depth === 0) {
+ return { content: text.slice(start + 1, i), start, end: i + 1 };
+ }
+ }
+ return null;
+}
- // figure 环境 -> 图片节点
- body = body.replace(/\\begin\{figure\}[\s\S]*?\\end\{figure\}/g, function(match) {
- const imgMatch = match.match(/\\includegraphics(?:\[[^\]]*\])?\{([^}]+)\}/);
- const capMatch = match.match(/\\caption\{([^}]+)\}/);
- const labelMatch = match.match(/\\label\{([^}]+)\}/);
- const path = imgMatch ? imgMatch[1] : '';
- const caption = capMatch ? capMatch[1] : '';
- const label = labelMatch ? labelMatch[1] : '';
- return '\n
\n';
- });
+function readTexCommand(text, start) {
+ const literal = readTexVerbatim(text, start);
+ if (literal) return literal;
+ const match = /^\\([A-Za-z@]+\*?|[^\r\n])/.exec(text.slice(start));
+ if (!match) return null;
+ let end = start + match[0].length;
+ const args = [];
+ if (match[1] === '\\') {
+ if (text[end] === '*') end++;
+ const optionStart = end + /^[ \t]*/.exec(text.slice(end))[0].length;
+ if (text[optionStart] === '[') {
+ const option = readTexGroup(text, optionStart, '[', ']');
+ if (option) end = option.end;
+ }
+ } else if (/^[A-Za-z@]/.test(match[1])) {
+ while (end < text.length) {
+ const spaces = /^[ \t\r\n]*/.exec(text.slice(end))[0].length;
+ const argStart = end + spaces;
+ const open = text[argStart];
+ if (open !== '{' && open !== '[') break;
+ const group = readTexGroup(text, argStart, open, open === '{' ? '}' : ']');
+ if (!group) break;
+ args.push({ ...group, open });
+ end = group.end;
+ }
+ }
+ return { name: match[1], start, end, args, source: text.slice(start, end) };
+}
- // itemize 环境
- body = body.replace(/\\begin\{itemize\}([\s\S]*?)\\end\{itemize\}/g, function(match, content) {
- const items = content.match(/\\item\s+[\s\S]*?(?=\\item|$)/g);
- if (!items) return '';
- const listHtml = items.map(function(item) {
- return ' ' + cleanLatexText(item.replace(/\\item\s+/, '')) + '';
- }).join('\n');
- return '\n\n';
- });
+// Literal environments must be isolated before looking for comments or nested commands.
+function readTexEnvironment(text, start) {
+ const begin = /^\\begin[ \t\r\n]*\{([^{}]+)\}/.exec(text.slice(start));
+ if (!begin) return null;
+ const name = begin[1];
+ const innerStart = start + begin[0].length;
+ const literal = new Set(['verbatim', 'verbatim*', 'Verbatim', 'lstlisting', 'minted', 'comment']);
+ function literalEnd(env, from) {
+ const pattern = new RegExp('\\\\end[ \\t\\r\\n]*\\{' + env.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}', 'g');
+ pattern.lastIndex = from;
+ const match = pattern.exec(text);
+ return match ? { innerEnd: match.index, end: pattern.lastIndex } : null;
+ }
+ if (literal.has(name)) {
+ const end = literalEnd(name, innerStart);
+ return { name, start, innerStart, ...(end || {innerEnd: text.length, end: text.length}), complete: Boolean(end) };
+ }
+ const stack = [name];
+ for (let i = innerStart; i < text.length;) {
+ if (text[i] === '%') {
+ const newline = text.indexOf('\n', i);
+ i = newline === -1 ? text.length : newline + 1;
+ continue;
+ }
+ if (text[i] !== '\\') { i++; continue; }
+ const boundary = /^\\(begin|end)[ \t\r\n]*\{([^{}]+)\}/.exec(text.slice(i));
+ if (boundary) {
+ const env = boundary[2];
+ if (boundary[1] === 'begin') {
+ if (literal.has(env)) {
+ const end = literalEnd(env, i + boundary[0].length);
+ if (!end) break;
+ i = end.end;
+ continue;
+ }
+ stack.push(env);
+ } else if (stack[stack.length - 1] === env) {
+ stack.pop();
+ if (stack.length === 0) return {name, start, innerStart, innerEnd: i, end: i + boundary[0].length, complete: true};
+ } else {
+ // An unmatched structure is never partially converted.
+ break;
+ }
+ i += boundary[0].length;
+ continue;
+ }
+ const command = readTexCommand(text, i);
+ i = command ? command.end : i + 1;
+ }
+ return {name, start, innerStart, innerEnd: text.length, end: text.length, complete: false};
+}
- // enumerate 环境
- body = body.replace(/\\begin\{enumerate\}([\s\S]*?)\\end\{enumerate\}/g, function(match, content) {
- const items = content.match(/\\item\s+[\s\S]*?(?=\\item|$)/g);
- if (!items) return '';
- const listHtml = items.map(function(item) {
- return ' ' + cleanLatexText(item.replace(/\\item\s+/, '')) + '';
- }).join('\n');
- return '\n\n' + listHtml + '\n
\n';
- });
+function readTexMath(text, start) {
+ let delimiter;
+ let close;
+ if (text.startsWith('$$', start)) { delimiter = '$$'; close = '$$'; }
+ else if (text[start] === '$') { delimiter = '$'; close = '$'; }
+ else if (text.startsWith('\\(', start)) { delimiter = '\\('; close = '\\)'; }
+ else if (text.startsWith('\\[', start)) { delimiter = '\\['; close = '\\]'; }
+ else return null;
+ for (let i = start + delimiter.length; i < text.length;) {
+ if (text.startsWith(close, i)) {
+ return {delimiter, content: text.slice(start + delimiter.length, i), end: i + close.length};
+ }
+ if (text[i] === '\\') { i += 2; continue; }
+ if (text[i] === '%') {
+ const newline = text.indexOf('\n', i);
+ i = newline === -1 ? text.length : newline + 1;
+ } else i++;
+ }
+ return null;
+}
- // 其余一切块级环境(table、abstract、thebibliography、lstlisting、algorithm、未知环境)
- // -> 原样保留块,用户可直接编辑文本,导出时原样输出
- // 先以占位符暂存,避免多行内容在逐行处理阶段被拆散、二次转义
- const rawBlocks = [];
- body = body.replace(/\\begin\{([A-Za-z]+\*?)\}[\s\S]*?\\end\{\1\}/g, function(match) {
- rawBlocks.push('' + escapeForHtml(match) + '
');
- return '\n\x00R' + (rawBlocks.length - 1) + '\x00\n';
- });
+// Character references prevent the HTML parser from normalizing CRLF in source attributes.
+function texSourceAttribute(source) {
+ return escapeAttr(source).replace(/\r/g, '
').replace(/\n/g, '
');
+}
- // 逐行处理章节、独立命令与普通文本
- const lines = body.split('\n');
- let pendingText = '';
+function texSourceElement(tag, source, content, attrs = '') {
+ const opening = '<' + tag + ' data-tex-source="' + texSourceAttribute(source) + '"' + attrs + '>';
+ return tag === 'img' || tag === 'br' ? opening : opening + content + '' + tag + '>';
+}
- function flushText() {
- const trimmed = pendingText.trim();
- if (trimmed) {
- html += '' + cleanLatexText(trimmed) + '
\n';
+function parseTexInline(text) {
+ let html = '';
+ let plain = '';
+ const flush = () => { html += escapeForHtml(plain); plain = ''; };
+ function commandSpan(source, hidden = false) {
+ return texSourceElement('span', source, escapeForHtml(source), ' class="tex-cmd" contenteditable="false"' + (hidden ? ' hidden' : ''));
+ }
+ for (let i = 0; i < text.length;) {
+ const math = readTexMath(text, i);
+ if (math) {
+ flush();
+ const block = math.delimiter === '$$' || math.delimiter === '\\[';
+ html += texSourceElement('span', text.slice(i, math.end), escapeForHtml(text.slice(i, math.end)),
+ ' class="' + (block ? 'formula-block' : 'formula-inline') + '" data-formula="' + texSourceAttribute(math.content) +
+ '" data-delimiter="' + escapeAttr(math.delimiter) + '" contenteditable="false"');
+ i = math.end;
+ continue;
}
- pendingText = '';
+ if (text[i] === '%') {
+ flush();
+ const newline = text.indexOf('\n', i);
+ const end = newline === -1 ? text.length : newline + 1;
+ html += commandSpan(text.slice(i, end), true);
+ i = end;
+ continue;
+ }
+ if (text[i] === '\\') {
+ flush();
+ const command = readTexCommand(text, i);
+ if (!command) { html += commandSpan(text[i++]); continue; }
+ const formats = {textbf: 'b', textit: 'i', emph: 'em'};
+ const tag = formats[command.name];
+ if (tag && command.args.length === 1 && command.args[0].open === '{') {
+ html += texSourceElement(tag, command.source, parseTexInline(command.args[0].content),
+ ' data-tex-command="' + command.name + '"');
+ } else if (command.name === '\\') {
+ html += texSourceElement('br', command.source, '');
+ } else {
+ html += commandSpan(command.source);
+ }
+ i = command.end;
+ continue;
+ }
+ // These characters are TeX syntax in imported text, not newly typed plain text.
+ if ('$_{}~^'.includes(text[i])) {
+ flush();
+ html += commandSpan(text[i++]);
+ continue;
+ }
+ plain += text[i++];
}
+ flush();
+ return html;
+}
- for (const line of lines) {
- const trimmed = line.trim();
-
- if (!trimmed) {
- flushText();
+function parseSimpleTexList(text, env) {
+ const body = text.slice(env.innerStart, env.innerEnd);
+ if (/\\begin\s*\{|\n[ \t\r]*\n/.test(body)) return null;
+ const items = [];
+ let current = null;
+ for (let i = 0; i < body.length;) {
+ const math = readTexMath(body, i);
+ if (math) { i = math.end; continue; }
+ if (body[i] === '%') {
+ const newline = body.indexOf('\n', i);
+ i = newline === -1 ? body.length : newline + 1;
continue;
}
+ if (body[i] === '\\') {
+ const command = readTexCommand(body, i);
+ if (command && command.name === 'item') {
+ // Optional labels and grouped items remain fully editable raw source.
+ if (command.args.length) return null;
+ if (current === null && body.slice(0, i).trim()) return null;
+ if (current !== null) items.push(body.slice(current, i));
+ current = command.end;
+ }
+ i = command ? command.end : i + 1;
+ } else i++;
+ }
+ if (current === null) return null;
+ items.push(body.slice(current));
+ const tag = env.name === 'itemize' ? 'ul' : 'ol';
+ return texSourceElement(tag, text.slice(env.start, env.end), items.map(item => '' + parseTexInline(item) + '').join(''));
+}
+
+function parseSimpleTexFigure(text, env) {
+ const body = text.slice(env.innerStart, env.innerEnd);
+ let i = 0;
+ let placement = '';
+ let image = null;
+ let caption = '';
+ let label = '';
+ const seen = new Set();
+ const skipSpaces = () => { i += /^[ \t\r\n]*/.exec(body.slice(i))[0].length; };
+ skipSpaces();
+ if (body[i] === '[') {
+ const option = readTexGroup(body, i, '[', ']');
+ if (!option || !/^[!htbpH]*$/.test(option.content)) return null;
+ placement = option.content;
+ i = option.end;
+ }
+ while (i < body.length) {
+ skipSpaces();
+ if (i === body.length) break;
+ const command = readTexCommand(body, i);
+ if (!command || seen.has(command.name)) return null;
+ seen.add(command.name);
+ if (command.name === 'centering' && command.args.length === 0) {
+ // No source is discarded: the complete figure is retained on the image node.
+ } else if (command.name === 'includegraphics') {
+ const args = command.args;
+ const pathArg = args[args.length - 1];
+ if (!pathArg || pathArg.open !== '{' || args.length > 2) return null;
+ const width = args.length === 2 && args[0].open === '[' ? /^width\s*=\s*((?:\d*\.)?\d+)\\textwidth$/.exec(args[0].content.trim()) : null;
+ if (args.length === 2 && !width) return null;
+ const path = pathArg.content;
+ if (!path || /[\\{}]|^[a-z][a-z\d+.-]*:|^\/\//i.test(path)) return null;
+ image = {path, width: width ? width[1] : ''};
+ } else if ((command.name === 'caption' || command.name === 'label') && command.args.length === 1 && command.args[0].open === '{') {
+ if (command.name === 'caption') caption = command.args[0].content;
+ else label = command.args[0].content;
+ } else return null;
+ i = command.end;
+ }
+ if (!image) return null;
+ return texSourceElement('img', text.slice(env.start, env.end), '',
+ ' src="' + escapeAttr(image.path) + '" alt="' + escapeAttr(caption) + '" data-caption="' + texSourceAttribute(caption) +
+ '" data-label="' + texSourceAttribute(label) + '" data-caption-tex="true" data-label-tex="true" data-width="' + escapeAttr(image.width) +
+ '" data-placement="' + escapeAttr(placement) + '" data-centering="' + seen.has('centering') +
+ '" data-has-caption="' + seen.has('caption') + '"');
+}
- // 原样保留块占位符 -> 输出完整块
- if (/^\x00R\d+\x00$/.test(trimmed)) {
- flushText();
- html += rawBlocks[parseInt(trimmed.replace(/\x00R|\x00/g, ''), 10)] + '\n';
+// Build visual nodes from source tokens only. Unknown structures keep their complete source.
+function parseTexToHtml(tex) {
+ tex = String(tex);
+ let html = '';
+ function gap(source) {
+ if (source) html += texSourceElement('span', source, '', ' class="tex-whitespace" contenteditable="false" hidden');
+ }
+ function paragraph(source) {
+ const leading = /^[ \t\r\n\f]*/.exec(source)[0];
+ gap(leading);
+ const rest = source.slice(leading.length);
+ const trailing = /[ \t\r\n\f]*$/.exec(rest)[0];
+ const content = rest.slice(0, rest.length - trailing.length);
+ if (content) html += texSourceElement('p', content, parseTexInline(content));
+ gap(trailing);
+ }
+ function headingAt(start) {
+ if (!/^\\(?:section|subsection|subsubsection)(?![A-Za-z@])/.test(tex.slice(start))) return null;
+ const command = readTexCommand(tex, start);
+ return command && command.args.length ? command : null;
+ }
+ let i = 0;
+ while (i < tex.length) {
+ const whitespace = /^[ \t\r\n\f]+/.exec(tex.slice(i));
+ if (whitespace) { gap(whitespace[0]); i += whitespace[0].length; continue; }
+ const env = readTexEnvironment(tex, i);
+ if (env) {
+ let visual = null;
+ if (env.complete && (env.name === 'itemize' || env.name === 'enumerate')) visual = parseSimpleTexList(tex, env);
+ if (env.complete && env.name === 'figure') visual = parseSimpleTexFigure(tex, env);
+ const source = tex.slice(i, env.end);
+ html += visual || texSourceElement('div', source, escapeForHtml(source), ' class="raw-tex"');
+ i = env.end;
continue;
}
-
- // 已处理的 HTML 标签直接输出
- if (/^' + cleanLatexText(m[1]) + '\n';
- } else if ((m = trimmed.match(/^\\subsection\{([^}]+)\}/))) {
- flushText();
- html += '' + cleanLatexText(m[1]) + '
\n';
- } else if ((m = trimmed.match(/^\\subsubsection\{([^}]+)\}/))) {
- flushText();
- html += '' + cleanLatexText(m[1]) + '
\n';
- } else if (/^\\[a-zA-Z]+/.test(trimmed)) {
- // 其余独立命令行(\maketitle、\newpage、\tableofcontents 等)原样保留
- flushText();
- html += '' + escapeForHtml(trimmed) + '
\n';
- } else {
- pendingText += (pendingText ? ' ' : '') + trimmed;
+ const start = i;
+ while (i < tex.length) {
+ if (/^[ \t\r]*\n[ \t\r]*\n/.test(tex.slice(i))) break;
+ const math = readTexMath(tex, i);
+ if (math) { i = math.end; continue; }
+ if (tex[i] === '%') {
+ const newline = tex.indexOf('\n', i);
+ i = newline === -1 ? tex.length : newline + 1;
+ continue;
+ }
+ if (tex[i] === '\\') {
+ if (i > start && (readTexEnvironment(tex, i) || headingAt(i))) break;
+ const command = readTexCommand(tex, i);
+ i = command ? command.end : i + 1;
+ } else i++;
}
+ paragraph(tex.slice(start, i));
}
- flushText();
-
return html;
}
@@ -462,30 +782,38 @@ function parseTexToHtml(tex) {
function domToLatex() {
let latex = '';
- // 有导入文件的导言区则原样使用,否则使用默认配置
- if (texPreamble) {
- latex += texPreamble + '\n\n';
- } else {
- latex += '\\documentclass[12pt,a4paper]{article}\n\n';
- latex += '% 中文支持\n';
- latex += '\\usepackage{ctex}\n\n';
- latex += '% 数学公式\n';
- latex += '\\usepackage{amsmath}\n';
- latex += '\\usepackage{amssymb}\n\n';
- latex += '% 图表\n';
- latex += '\\usepackage{graphicx}\n';
- latex += '\\usepackage{float}\n\n';
- latex += '% 页面布局\n';
- latex += '\\usepackage{geometry}\n';
- latex += '\\geometry{left=2.5cm,right=2.5cm,top=2.5cm,bottom=2.5cm}\n\n';
+ if (texPreamble !== null) {
+ let preamble = texPreamble;
+ const changedImages = Array.from(editorArea.querySelectorAll('img')).some(function(node) {
+ return node.getAttribute('data-tex-source') === null ||
+ node.getAttribute('data-tex-snapshot') !== getTexNodeSnapshot(node);
+ });
+ // 仅为新插入/修改的图片补依赖,未修改的导入文档不改动导言区。
+ if (changedImages && !hasTexPackage(preamble, 'graphicx')) {
+ const newline = preamble.includes('\r\n') ? '\r\n' : '\n';
+ preamble += (preamble.endsWith('\n') ? '' : newline) + '\\usepackage{graphicx}' + newline;
+ }
+ const body = convertEditorNodesToLatex(editorArea.childNodes);
+ return preamble + texDocumentOpen + body + texDocumentClose + texTail;
}
+ // 新建文档使用默认配置;导入文档已在上方按原始边界返回。
+ latex += '\\documentclass[12pt,a4paper]{article}\n\n';
+ latex += '% 中文支持\n';
+ latex += '\\usepackage{ctex}\n\n';
+ latex += '% 数学公式\n';
+ latex += '\\usepackage{amsmath}\n';
+ latex += '\\usepackage{amssymb}\n\n';
+ latex += '% 图表\n';
+ latex += '\\usepackage{graphicx}\n';
+ latex += '\\usepackage{float}\n\n';
+ latex += '% 页面布局\n';
+ latex += '\\usepackage{geometry}\n';
+ latex += '\\geometry{left=2.5cm,right=2.5cm,top=2.5cm,bottom=2.5cm}\n\n';
+
latex += '\\begin{document}\n\n';
- const nodes = editorArea.childNodes;
- for (let i = 0; i < nodes.length; i++) {
- latex += convertNodeToLatex(nodes[i]);
- }
+ latex += convertEditorNodesToLatex(editorArea.childNodes);
latex += '\n\\end{document}\n';
if (texTail) {
@@ -495,169 +823,193 @@ function domToLatex() {
return latex;
}
-// 转换单个节点为 LaTeX
-function convertNodeToLatex(node) {
- let latex = '';
+function hasTexPackage(preamble, packageName) {
+ const withoutComments = preamble.replace(/(^|[^\\])%.*/g, '$1');
+ const packages = withoutComments.matchAll(/\\(?:usepackage|RequirePackage)\s*(?:\[[^\]]*\]\s*)?\{([^}]+)\}/g);
+ return Array.from(packages).some(function(match) {
+ return match[1].split(',').some(name => name.trim() === packageName);
+ });
+}
- if (node.nodeType === Node.TEXT_NODE) {
- const text = node.textContent.trim();
- if (text) {
- return escapeLatex(text) + '\n';
- }
- return '';
+// 同时检查内容和根节点属性:居中、标题级别或公式属性变化也必须使原文缓存失效。
+function getTexNodeSnapshot(node) {
+ function describe(current) {
+ if (current.nodeType !== Node.ELEMENT_NODE) return [current.nodeType, current.textContent];
+ const attributes = Array.from(current.attributes || [])
+ .filter(function(attr) { return !attr.name.startsWith('data-tex-'); })
+ .map(function(attr) { return [attr.name, attr.value]; })
+ .sort(function(a, b) { return a[0].localeCompare(b[0]); });
+ return [current.tagName.toLowerCase(), attributes, Array.from(current.childNodes).map(describe)];
}
+ // 不把子节点的原文和快照再次嵌进父快照,防止嵌套格式造成元数据膨胀。
+ return JSON.stringify(describe(node));
+}
- if (node.nodeType !== Node.ELEMENT_NODE) {
- return '';
+// 浏览器按 Enter 拆分段落时,不一定为新兄弟节点插入源文件的空白占位符。
+function convertEditorNodesToLatex(nodes) {
+ function ordinaryParagraph(node) {
+ return node && node.nodeType === Node.ELEMENT_NODE && /^(p|div)$/i.test(node.tagName) &&
+ !node.classList.contains('raw-tex') && !node.classList.contains('formula-block');
}
-
- // 原样保留块:直接输出其文本内容
- if (node.classList.contains('raw-tex')) {
- const raw = node.textContent.replace(/\s+$/, '');
- if (raw) {
- return raw + '\n\n';
+ function unchanged(node) {
+ return node.getAttribute('data-tex-source') !== null &&
+ node.getAttribute('data-tex-snapshot') === getTexNodeSnapshot(node);
+ }
+ let latex = '';
+ let previous = null;
+ Array.from(nodes).forEach(function(node) {
+ const part = convertNodeToLatex(node);
+ if (ordinaryParagraph(previous) && ordinaryParagraph(node) && !(unchanged(previous) && unchanged(node))) {
+ const boundaryWhitespace = (latex.match(/\s*$/)[0] || '') + (part.match(/^\s*/)[0] || '');
+ const newlineCount = (boundaryWhitespace.match(/\n/g) || []).length;
+ if (newlineCount < 2) latex += '\n'.repeat(2 - newlineCount);
}
- return '';
+ latex += part;
+ previous = node;
+ });
+ return latex;
+}
+
+// contenteditable 的 Enter 会产生 div/br;textContent 会吞掉这些换行。
+function getRawTexContent(node) {
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent;
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
+ if (node.tagName.toLowerCase() === 'br') return '\n';
+ let text = '';
+ const children = Array.from(node.childNodes);
+ children.forEach(function(child, index) {
+ const isBlock = child.nodeType === Node.ELEMENT_NODE && /^(div|p|pre|li)$/.test(child.tagName.toLowerCase());
+ if (isBlock && text && !text.endsWith('\n')) text += '\n';
+ text += getRawTexContent(child);
+ if (isBlock && index < children.length - 1 && !text.endsWith('\n')) text += '\n';
+ });
+ return text;
+}
+
+// 所有层级共用转换规则,避免段落内图片/换行及根节点公式走不同路径。
+function convertNodeToLatex(node) {
+ if (node.nodeType === Node.TEXT_NODE) return escapeLatex(node.textContent);
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
+
+ const source = node.getAttribute('data-tex-source');
+ const snapshot = node.getAttribute('data-tex-snapshot');
+ if (source !== null && snapshot !== null && getTexNodeSnapshot(node) === snapshot) {
+ return source;
+ }
+ // 导入节点的分隔空白由独立的 tex-whitespace 节点保管,编辑时不额外插入空行。
+ const separator = source !== null ? '' : '\n\n';
+ if (node.classList.contains('tex-whitespace')) return source || '';
+ if (node.classList.contains('raw-tex')) return getRawTexContent(node);
+ if (node.classList.contains('tex-cmd')) return node.textContent;
+
+ if (node.classList.contains('formula-inline') || node.classList.contains('formula-block')) {
+ const formula = node.getAttribute('data-formula') || '';
+ const delimiter = node.getAttribute('data-delimiter');
+ if (delimiter === '$$') return '$$' + formula + '$$';
+ if (delimiter === '\\[') return '\\[' + formula + '\\]';
+ if (delimiter === '\\(') return '\\(' + formula + '\\)';
+ if (delimiter === '$' || node.classList.contains('formula-inline')) return '$' + formula + '$';
+ return '\\begin{equation}\n' + formula + '\n\\end{equation}' + separator;
}
const tagName = node.tagName.toLowerCase();
-
+ let latex;
switch (tagName) {
case 'h1':
- latex += '\\section{' + getNodeTextContent(node) + '}\n\n';
- break;
-
case 'h2':
- latex += '\\subsection{' + getNodeTextContent(node) + '}\n\n';
- break;
-
- case 'h3':
- latex += '\\subsubsection{' + getNodeTextContent(node) + '}\n\n';
+ case 'h3': {
+ const command = {h1: 'section', h2: 'subsection', h3: 'subsubsection'}[tagName];
+ latex = '\\' + command + '{' + convertInlineNodes(node) + '}' + separator;
break;
-
+ }
case 'p':
case 'div':
- if (node.classList.contains('formula-block')) {
- const formula = node.getAttribute('data-formula');
- latex += '\\begin{equation}\n';
- latex += ' ' + formula + '\n';
- latex += '\\end{equation}\n\n';
- } else {
- latex += convertInlineNodes(node) + '\n\n';
- }
+ latex = convertInlineNodes(node) + separator;
break;
-
- case 'ul': {
- latex += '\\begin{itemize}\n';
- const ulItems = node.querySelectorAll(':scope > li');
- ulItems.forEach(function(item) {
- latex += ' \\item ' + getNodeTextContent(item) + '\n';
- });
- latex += '\\end{itemize}\n\n';
+ case 'b':
+ case 'strong':
+ latex = '\\textbf{' + convertInlineNodes(node) + '}';
break;
- }
-
+ case 'i':
+ case 'em':
+ latex = '\\' + (node.getAttribute('data-tex-command') === 'emph' ? 'emph' : 'textit') + '{' + convertInlineNodes(node) + '}';
+ break;
+ case 'ul':
case 'ol': {
- latex += '\\begin{enumerate}\n';
- const olItems = node.querySelectorAll(':scope > li');
- olItems.forEach(function(item) {
- latex += ' \\item ' + getNodeTextContent(item) + '\n';
+ const environment = tagName === 'ul' ? 'itemize' : 'enumerate';
+ latex = '\\begin{' + environment + '}\n';
+ Array.from(node.childNodes).forEach(function(child) {
+ if (child.nodeType === Node.ELEMENT_NODE && child.tagName.toLowerCase() === 'li') {
+ const label = child.getAttribute('data-item-label');
+ latex += ' \\item' + (label === null ? '' : '[' + label + ']') + ' ' + convertInlineNodes(child) + '\n';
+ } else if (child.nodeType !== Node.TEXT_NODE || child.textContent.trim()) {
+ latex += convertNodeToLatex(child);
+ }
});
- latex += '\\end{enumerate}\n\n';
+ latex += '\\end{' + environment + '}' + separator;
break;
}
-
case 'img': {
const imgPath = node.getAttribute('src') || '';
- const imgCaption = node.getAttribute('data-caption') || '图片';
- const imgLabel = node.getAttribute('data-label') || 'fig:image';
- const imgWidth = node.getAttribute('data-width') || '0.6';
-
- latex += '\\begin{figure}[H]\n';
- latex += ' \\centering\n';
- latex += ' \\includegraphics[width=' + imgWidth + '\\textwidth]{' + imgPath + '}\n';
- latex += ' \\caption{' + imgCaption + '}\n';
- latex += ' \\label{' + imgLabel + '}\n';
- latex += '\\end{figure}\n\n';
+ const caption = node.getAttribute('data-caption');
+ const label = node.getAttribute('data-label');
+ const width = node.getAttribute('data-width');
+ const placement = node.getAttribute('data-placement');
+ latex = '\\begin{figure}' + (placement === '' ? '' : '[' + (placement || 'htbp') + ']') + '\n';
+ if (node.getAttribute('data-centering') !== 'false') latex += ' \\centering\n';
+ const imageOptions = width === '' ? '' : '[width=' + (width || '0.6') + '\\textwidth]';
+ latex += ' \\includegraphics' + imageOptions + '{' + imgPath + '}\n';
+ if (caption || node.getAttribute('data-has-caption') === 'true') {
+ latex += ' \\caption{' + (node.getAttribute('data-caption-tex') === 'true' ? caption || '' : escapeLatex(caption || '')) + '}\n';
+ }
+ if (label) latex += ' \\label{' + (node.getAttribute('data-label-tex') === 'true' ? label : escapeLatex(label)) + '}\n';
+ latex += '\\end{figure}' + separator;
break;
}
-
case 'br':
- // LaTeX 换行符 \\ + 换行(92 为反斜杠字符码)
- latex += String.fromCharCode(92, 92) + '\n';
- break;
-
+ return '\\\\\n';
default:
- for (let i = 0; i < node.childNodes.length; i++) {
- latex += convertNodeToLatex(node.childNodes[i]);
- }
+ latex = convertInlineNodes(node);
}
+ const alignment = (node.style && node.style.textAlign) || node.getAttribute('align');
+ if (alignment === 'center' && /^(p|div|h[1-3])$/.test(tagName)) {
+ // 局部作用域避免居中设置泄漏到后续段落。
+ return '\\begin{center}\n' + latex + '\n\\end{center}' + separator;
+ }
return latex;
}
-// 转换行内节点(处理粗体、斜体、公式、原样命令等)
function convertInlineNodes(node) {
let latex = '';
-
- for (let i = 0; i < node.childNodes.length; i++) {
- const child = node.childNodes[i];
-
- if (child.nodeType === Node.TEXT_NODE) {
- latex += escapeLatex(child.textContent);
- } else if (child.nodeType === Node.ELEMENT_NODE) {
- const tag = child.tagName.toLowerCase();
-
- if (child.classList.contains('formula-inline')) {
- latex += '$' + child.getAttribute('data-formula') + '$';
- } else if (child.classList.contains('tex-cmd')) {
- latex += child.textContent;
- } else if (tag === 'b' || tag === 'strong') {
- latex += '\\textbf{' + getNodeTextContent(child) + '}';
- } else if (tag === 'i' || tag === 'em') {
- latex += '\\textit{' + getNodeTextContent(child) + '}';
- } else {
- latex += getNodeTextContent(child);
- }
- }
- }
-
+ let pendingComment = false;
+ Array.from(node.childNodes).forEach(function(child) {
+ const part = convertNodeToLatex(child);
+ if (!part) return;
+ // 段尾注释的换行可能保存在外部空白节点中;追加文字时必须先结束注释。
+ if (pendingComment && !/^[ \t]*[\r\n]/.test(part)) latex += '\n';
+ latex += part;
+ pendingComment = child.nodeType === Node.ELEMENT_NODE && child.classList.contains('tex-cmd') &&
+ child.getAttribute('hidden') !== null && part.startsWith('%') && !/[\r\n]/.test(part);
+ });
+ // 新增格式包裹段尾注释时,也不能让注释吞掉生成的右花括号。
+ if (pendingComment && /^(b|strong|i|em|h[1-3])$/i.test(node.tagName)) latex += '\n';
return latex;
}
-// 获取节点的纯文本内容(行内公式还原为 $...$)
+// 保留兼容入口;内部节点也使用统一转换,不能把格式和公式降为纯文本。
function getNodeTextContent(node) {
- let text = '';
- for (let i = 0; i < node.childNodes.length; i++) {
- const child = node.childNodes[i];
- if (child.nodeType === Node.TEXT_NODE) {
- text += child.textContent;
- } else if (child.nodeType === Node.ELEMENT_NODE) {
- if (child.classList.contains('formula-inline')) {
- text += '$' + child.getAttribute('data-formula') + '$';
- } else {
- text += getNodeTextContent(child);
- }
- }
- }
- return text;
+ return convertInlineNodes(node);
}
-// 转义 LaTeX 特殊字符
+// 此函数只处理普通文字;导入命令和转义符由 tex-cmd 节点保留原文。
function escapeLatex(text) {
const specialChars = {
- '&': '\\&',
- '%': '\\%',
- '$': '\\$',
- '#': '\\#',
- '_': '\\_',
- '{': '\\{',
- '}': '\\}',
- '~': '\\textasciitilde{}',
- '^': '\\textasciicircum{}'
+ '\\': '\\textbackslash{}',
+ '&': '\\&', '%': '\\%', '$': '\\$', '#': '\\#', '_': '\\_',
+ '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}'
};
-
- return text.replace(/[&%$#_{}~^]/g, function(match) {
+ return String(text).replace(/[\\&%$#_{}~^]/g, function(match) {
return specialChars[match];
});
}
@@ -666,7 +1018,7 @@ function escapeLatex(text) {
function updatePreview() {
const latex = domToLatex();
previewArea.textContent = latex;
- updateStatus('已更新');
+ if (!draftRecoveryFailed) updateStatus('已更新');
}
// 复制 LaTeX 代码
@@ -698,34 +1050,105 @@ function downloadTexFile() {
// 保存草稿到 localStorage(含导言区/尾部/文件名)
function saveDraft() {
+ if (suppressDraftSave) return false;
+ if (draftRecoveryFailed) {
+ updateStatus('原草稿恢复失败,已暂停自动覆盖;请下载当前内容或重新导入');
+ return false;
+ }
try {
- localStorage.setItem('visuellatex_draft', editorArea.innerHTML);
- localStorage.setItem('visuellatex_preamble', texPreamble || '');
- localStorage.setItem('visuellatex_tail', texTail || '');
- localStorage.setItem('visuellatex_filename', importedFileName || '');
- localStorage.setItem('visuellatex_draft_time', new Date().toISOString());
+ // 单次写入,避免配额不足时正文和导言区处于不同版本。
+ localStorage.setItem('visuellatex_document', JSON.stringify({
+ version: 1, html: editorArea.innerHTML.replace(/\r/g, '
'), preamble: texPreamble,
+ tail: texTail, open: texDocumentOpen, close: texDocumentClose,
+ filename: importedFileName, savedAt: new Date().toISOString()
+ }));
+ updateStatus('草稿已保存');
+ return true;
} catch (e) {
console.error('保存草稿失败:', e);
+ updateStatus('草稿保存失败,请下载文件保存当前内容');
+ return false;
+ }
+}
+
+function flushDraft() {
+ clearTimeout(debounceTimer);
+ if (editorArea) saveDraft();
+}
+
+// 旧版草稿可能含导入产生的活动 HTML;在惰性的 template 内清理后再恢复。
+function sanitizeDraftHtml(html) {
+ const template = document.createElement('template');
+ template.innerHTML = html;
+ const allowedTags = new Set(['P', 'DIV', 'SPAN', 'H1', 'H2', 'H3', 'UL', 'OL', 'LI', 'B', 'STRONG', 'I', 'EM', 'BR', 'IMG']);
+ const allowedAttributes = new Set(['class', 'contenteditable', 'hidden', 'src', 'alt', 'align', 'title',
+ 'data-caption', 'data-label', 'data-width', 'data-formula', 'data-delimiter',
+ 'data-placement', 'data-centering', 'data-has-caption', 'data-caption-tex', 'data-label-tex', 'data-item-label',
+ 'data-tex-command', 'data-tex-source', 'data-tex-snapshot']);
+ for (const node of Array.from(template.content.querySelectorAll('*'))) {
+ if (!allowedTags.has(node.tagName)) {
+ if (/^(SCRIPT|STYLE|IFRAME|OBJECT|EMBED|SVG|MATH|TEMPLATE)$/.test(node.tagName)) node.remove();
+ else node.replaceWith(...Array.from(node.childNodes));
+ continue;
+ }
+ for (const attr of Array.from(node.attributes)) {
+ if (attr.name === 'style') {
+ const align = node.style.textAlign;
+ node.removeAttribute('style');
+ if (/^(center|left|right|justify)$/.test(align)) node.style.textAlign = align;
+ } else if (!allowedAttributes.has(attr.name)) {
+ node.removeAttribute(attr.name);
+ }
+ }
+ const src = node.getAttribute('src');
+ const normalizedSrc = src && src.replace(/[\u0000-\u0020\u007f]/g, '');
+ if (normalizedSrc && /^(?:javascript|vbscript|data):/i.test(normalizedSrc) && !/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(normalizedSrc)) {
+ node.removeAttribute('src');
+ }
}
+ return template.innerHTML.replace(/\r/g, '
');
}
// 从 localStorage 加载草稿
function loadDraft() {
try {
+ const serialized = localStorage.getItem('visuellatex_document');
+ if (serialized !== null) {
+ const draft = JSON.parse(serialized);
+ if (draft.version !== 1 || typeof draft.html !== 'string') throw new Error('草稿格式无效');
+ editorArea.innerHTML = sanitizeDraftHtml(draft.html);
+ texPreamble = typeof draft.preamble === 'string' ? draft.preamble : null;
+ texTail = typeof draft.tail === 'string' ? draft.tail : '';
+ texDocumentOpen = typeof draft.open === 'string' ? draft.open : '\\begin{document}';
+ texDocumentClose = typeof draft.close === 'string' ? draft.close : '\\end{document}';
+ importedFileName = typeof draft.filename === 'string' ? draft.filename : null;
+ updateStatus('已恢复草稿');
+ return;
+ }
const content = localStorage.getItem('visuellatex_draft');
const time = localStorage.getItem('visuellatex_draft_time');
- if (content) {
- editorArea.innerHTML = content;
+ if (content !== null) {
+ editorArea.innerHTML = sanitizeDraftHtml(content);
texPreamble = localStorage.getItem('visuellatex_preamble') || null;
texTail = localStorage.getItem('visuellatex_tail') || '';
importedFileName = localStorage.getItem('visuellatex_filename') || null;
+ if (texPreamble !== null) {
+ // 旧草稿的导言区/尾部经过 trim,需恢复旧导出的边界换行,
+ // 否则末尾注释会吞掉紧接的 begin/end{document}。
+ texPreamble += '\n\n';
+ texDocumentOpen = '\\begin{document}\n\n';
+ texDocumentClose = '\n\\end{document}\n';
+ if (texTail) texTail += '\n';
+ }
if (time) {
updateStatus('已恢复草稿(' + new Date(time).toLocaleString('zh-CN') + ')');
}
}
} catch (e) {
console.error('加载草稿失败:', e);
+ draftRecoveryFailed = true;
+ updateStatus('草稿恢复失败,请勿覆盖现有草稿');
}
}
@@ -776,12 +1199,18 @@ const SYMBOL_PALETTE = [
{ label: 'Σ', latex: '\\Sigma' }
];
-// 打开公式弹窗
-function openFormulaModal(mode) {
+let editingFormulaNode = null;
+
+// 打开公式弹窗;传入已有节点时编辑该公式。
+function openFormulaModal(mode, formulaNode = null) {
const modal = document.getElementById('formulaModal');
// 保存编辑器选区(弹窗聚焦会劫持 DOM 选区)
saveEditorSelection();
+ editingFormulaNode = formulaNode && editorArea.contains(formulaNode) ? formulaNode : null;
+ document.getElementById('formulaInput').value = editingFormulaNode
+ ? editingFormulaNode.getAttribute('data-formula') || '' : '';
+ document.getElementById('btnInsertFormula').textContent = editingFormulaNode ? '更新公式' : '插入公式';
const radio = document.querySelector('input[name="formulaMode"][value="' + (mode || 'inline') + '"]');
if (radio) radio.checked = true;
@@ -793,6 +1222,8 @@ function openFormulaModal(mode) {
// 关闭公式弹窗
function closeFormulaModal() {
document.getElementById('formulaModal').classList.remove('show');
+ editingFormulaNode = null;
+ document.getElementById('btnInsertFormula').textContent = '插入公式';
if (editorArea) editorArea.focus();
}
@@ -824,40 +1255,61 @@ function insertSymbolIntoTextarea(symbol) {
// 从手动输入插入公式
function insertFormulaFromInput() {
- const latex = document.getElementById('formulaInput').value.trim();
- if (!latex) {
+ const latex = document.getElementById('formulaInput').value;
+ if (!latex.trim()) {
updateStatus('请输入公式内容');
return;
}
const mode = document.querySelector('input[name="formulaMode"]:checked').value;
- insertFormulaNode(latex, mode);
+ if (editingFormulaNode && editorArea.contains(editingFormulaNode)) {
+ const old = editingFormulaNode;
+ const oldMode = old.classList.contains('formula-block') ? 'block' : 'inline';
+ if (mode === oldMode && old.getAttribute('data-formula') === latex) {
+ closeFormulaModal();
+ return;
+ }
+ // 保留原标签,避免把段落内的显示公式变成非法的 div 嵌套。
+ const replacement = document.createElement(mode === 'inline' ? 'span' : old.tagName.toLowerCase());
+ configureFormulaNode(replacement, latex, mode);
+ const delimiter = old.getAttribute('data-delimiter');
+ if (mode === oldMode && delimiter) {
+ replacement.setAttribute('data-delimiter', delimiter);
+ const close = delimiter === '\\(' ? '\\)' : delimiter === '\\[' ? '\\]' : delimiter;
+ replacement.textContent = delimiter + latex + close;
+ } else if (mode === oldMode && mode === 'block') {
+ // 兼容旧草稿中没有定界符属性的编号 equation 节点。
+ replacement.setAttribute('data-delimiter', 'equation');
+ }
+ // 新节点不携带旧源码快照,祖先的快照比较也会检测到此次修改。
+ old.parentNode.replaceChild(replacement, old);
+ updatePreview();
+ if (saveDraft()) updateStatus('公式已更新');
+ } else {
+ insertFormulaNode(latex, mode);
+ }
document.getElementById('formulaInput').value = '';
closeFormulaModal();
}
-// 在编辑器光标处插入公式节点(只读,防止误改导致数据不同步)
+function configureFormulaNode(node, latex, type) {
+ const delimiter = type === 'block' ? '$$' : '$';
+ node.className = type === 'block' ? 'formula-block' : 'formula-inline';
+ node.setAttribute('data-formula', latex);
+ node.setAttribute('data-delimiter', delimiter);
+ node.setAttribute('contenteditable', 'false');
+ node.title = '双击编辑公式';
+ node.textContent = delimiter + latex + delimiter;
+}
+
+// 在编辑器光标处插入公式节点(只读,双击通过弹窗修改)
function insertFormulaNode(latex, type) {
- // 恢复编辑器中的选区
restoreEditorSelection();
-
- if (type === 'block') {
- const div = document.createElement('div');
- div.className = 'formula-block';
- div.setAttribute('data-formula', latex);
- div.setAttribute('contenteditable', 'false');
- div.textContent = '$$' + latex + '$$';
- insertNodeAtCursor(div);
- } else {
- const span = document.createElement('span');
- span.className = 'formula-inline';
- span.setAttribute('data-formula', latex);
- span.setAttribute('contenteditable', 'false');
- span.textContent = '$' + latex + '$';
- insertNodeAtCursor(span);
- }
+ // span 可安全放入段落,显示公式由 CSS display:block 呈现。
+ const node = document.createElement('span');
+ configureFormulaNode(node, latex, type);
+ if (!insertNodeAtCursor(node)) return;
updatePreview();
- saveDraft();
- updateStatus('公式已插入');
+ if (saveDraft()) updateStatus('公式已插入');
}
// 恢复编辑器选区(无有效选区时定位到编辑器末尾)
@@ -886,6 +1338,14 @@ function restoreEditorSelection() {
function initFormulaModal() {
renderSymbolPalette();
+ editorArea.addEventListener('dblclick', function(e) {
+ const target = e.target.nodeType === Node.ELEMENT_NODE ? e.target : e.target.parentElement;
+ const formula = target && target.closest ? target.closest('.formula-inline, .formula-block') : null;
+ if (!formula || !editorArea.contains(formula)) return;
+ e.preventDefault();
+ openFormulaModal(formula.classList.contains('formula-block') ? 'block' : 'inline', formula);
+ });
+
// 关闭按钮
document.getElementById('formulaModalClose').addEventListener('click', closeFormulaModal);
diff --git a/style.css b/style.css
index dea5694..6b62551 100644
--- a/style.css
+++ b/style.css
@@ -231,6 +231,7 @@ body {
/* 行间公式(暖纸底 + 强调色左边线) */
.editor-area .formula-block {
+ display: block;
background: var(--surface-soft);
padding: 12px;
border-radius: var(--radius);
diff --git a/tests/editor-regression.test.mjs b/tests/editor-regression.test.mjs
new file mode 100644
index 0000000..7238ed5
--- /dev/null
+++ b/tests/editor-regression.test.mjs
@@ -0,0 +1,242 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+
+const source = fs.readFileSync(new URL('../main.js', import.meta.url), 'utf8');
+
+// Minimal selection/DOM model: exercises the real event handlers without dependencies.
+function fixture() {
+ class Element {
+ constructor(tag) {
+ this.nodeType = 1;
+ this.tagName = tag.toUpperCase();
+ this.attributes = new Map();
+ this.childNodes = [];
+ this.listeners = {};
+ this.value = '';
+ this.classList = {
+ contains: name => this.className.split(/\s+/).includes(name),
+ add: name => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), name])].join(' '); },
+ remove: name => { this.className = this.className.split(/\s+/).filter(c => c !== name).join(' '); },
+ };
+ }
+ get className() { return this.attributes.get('class') || ''; }
+ set className(value) { this.attributes.set('class', value); }
+ get parentElement() { return this.parentNode || null; }
+ get textContent() { return this.childNodes.map(child => child.textContent).join(''); }
+ set textContent(value) { this.childNodes = []; this.appendChild(textNode(value)); }
+ setAttribute(name, value) { this.attributes.set(name, String(value)); }
+ getAttribute(name) { return this.attributes.get(name) ?? null; }
+ removeAttribute(name) { this.attributes.delete(name); }
+ appendChild(child) { child.parentNode = this; this.childNodes.push(child); return child; }
+ replaceChild(next, old) {
+ const index = this.childNodes.indexOf(old);
+ assert.notEqual(index, -1);
+ this.childNodes[index] = next;
+ next.parentNode = this;
+ old.parentNode = null;
+ }
+ contains(child) { return child === this || this.childNodes.some(node => node === child || node.contains?.(child)); }
+ closest(selector) {
+ for (let node = this; node; node = node.parentElement) {
+ if (selector.split(',').some(part => node.classList.contains(part.trim().slice(1)))) return node;
+ }
+ return null;
+ }
+ addEventListener(type, handler) { this.listeners[type] = handler; }
+ focus() {}
+ }
+ function textNode(value) {
+ return { nodeType: 3, textContent: String(value), get parentElement() { return this.parentNode; } };
+ }
+ class Range {
+ constructor(container, offset = 0) { this.commonAncestorContainer = container; this.offset = offset; this.deleted = false; }
+ selectNodeContents(container) { this.commonAncestorContainer = container; this.offset = 0; }
+ collapse(toStart) { if (!toStart) this.offset = this.commonAncestorContainer.childNodes.length; }
+ deleteContents() { this.deleted = true; }
+ insertNode(node) {
+ node.parentNode = this.commonAncestorContainer;
+ this.commonAncestorContainer.childNodes.splice(this.offset, 0, node);
+ }
+ setStartAfter(node) { this.commonAncestorContainer = node.parentNode; this.offset = node.parentNode.childNodes.indexOf(node) + 1; }
+ setEndAfter() {}
+ cloneRange() { return new Range(this.commonAncestorContainer, this.offset); }
+ }
+ const ids = Object.fromEntries(['editorArea', 'formulaModal', 'formulaInput', 'btnInsertFormula',
+ 'formulaModalClose', 'symbolPalette'].map(id => [id, new Element('div')]));
+ const radios = ['inline', 'block'].map(value => ({ value, selected: value === 'inline' }));
+ for (const radio of radios) Object.defineProperty(radio, 'checked', {
+ get: () => radio.selected,
+ set: value => { for (const other of radios) other.selected = other === radio && value; },
+ });
+ const selection = {
+ ranges: [],
+ get rangeCount() { return this.ranges.length; },
+ get anchorNode() { return this.ranges[0]?.commonAncestorContainer || null; },
+ getRangeAt(index) { return this.ranges[index]; },
+ removeAllRanges() { this.ranges = []; },
+ addRange(range) { this.ranges.push(range); },
+ };
+ const prompts = [];
+ const calls = { prompts: 0, saves: 0, previews: 0, statuses: [] };
+ const document = {
+ addEventListener() {},
+ getElementById: id => ids[id],
+ createElement: tag => new Element(tag),
+ createTextNode: textNode,
+ createRange: () => new Range(ids.editorArea),
+ querySelector: selector => selector.includes(':checked') ? radios.find(r => r.checked)
+ : radios.find(r => selector.includes(`value="${r.value}"`)),
+ };
+ const context = vm.createContext({ document, Node: { ELEMENT_NODE: 1, TEXT_NODE: 3 },
+ window: { getSelection: () => selection }, console,
+ prompt: () => { calls.prompts++; return prompts.shift() ?? null; },
+ __calls: calls, __editor: ids.editorArea });
+ vm.runInContext(source, context);
+ vm.runInContext(`editorArea=__editor;
+ updatePreview=()=>__calls.previews++;
+ saveDraft=()=>__calls.saves++;
+ updateStatus=message=>__calls.statuses.push(message);`, context);
+ function formula(latex, mode = 'inline') {
+ const node = new Element(mode === 'inline' ? 'span' : 'div');
+ context.configureFormulaNode(node, latex, mode);
+ ids.editorArea.appendChild(node);
+ return node;
+ }
+ return { context, ids, selection, prompts, calls, Element, Range, formula, radios };
+}
+
+test('image insertion without an existing selection appends to the editor', () => {
+ const f = fixture();
+ f.prompts.push('figure.png', 'A caption', 'fig:test');
+ f.context.insertImage();
+ assert.equal(f.ids.editorArea.childNodes[0].tagName, 'IMG');
+ assert.equal(f.ids.editorArea.childNodes[0].src, 'figure.png');
+ assert.equal(f.ids.editorArea.childNodes[0].getAttribute('data-caption'), 'A caption');
+ assert.equal(f.calls.saves, 1);
+});
+
+test('an external selection is never deleted or used for insertion', () => {
+ const f = fixture();
+ const outside = new f.Element('div');
+ const externalRange = new f.Range(outside);
+ f.selection.addRange(externalRange);
+ f.prompts.push('figure.png', '', '');
+ f.context.insertImage();
+ assert.equal(externalRange.deleted, false);
+ assert.equal(outside.childNodes.length, 0);
+ assert.equal(f.ids.editorArea.childNodes[0].tagName, 'IMG');
+});
+
+test('cancelling the image path immediately stops the interaction', () => {
+ const f = fixture();
+ f.prompts.push(null);
+ f.context.insertImage();
+ assert.equal(f.calls.prompts, 1);
+ assert.equal(f.calls.saves, 0);
+ assert.equal(f.ids.editorArea.childNodes.length, 0);
+});
+
+test('rich nodes are inserted after a raw source block, without deleting its source', () => {
+ const f = fixture();
+ const raw = new f.Element('div');
+ raw.className = 'raw-tex';
+ raw.textContent = String.raw`\begin{table}original\end{table}`;
+ f.ids.editorArea.appendChild(raw);
+ f.selection.addRange(new f.Range(raw));
+ const image = new f.Element('img');
+ f.context.insertNodeAtCursor(image);
+ assert.equal(raw.textContent, String.raw`\begin{table}original\end{table}`);
+ assert.equal(f.ids.editorArea.childNodes[1], image);
+});
+
+test('double-clicking an existing formula loads its value and mode', () => {
+ const f = fixture();
+ const formula = f.formula('x^2', 'block');
+ f.context.initFormulaModal();
+ let prevented = false;
+ f.ids.editorArea.listeners.dblclick({ target: formula, preventDefault() { prevented = true; } });
+ assert.equal(prevented, true);
+ assert.equal(f.ids.formulaInput.value, 'x^2');
+ assert.equal(f.radios.find(r => r.checked).value, 'block');
+ assert.equal(f.ids.btnInsertFormula.textContent, '更新公式');
+});
+
+test('editing an existing formula replaces it once and invalidates old source metadata', () => {
+ const f = fixture();
+ const formula = f.formula('x');
+ formula.setAttribute('data-delimiter', String.raw`\(`);
+ formula.setAttribute('data-tex-source', String.raw`\(x\)`);
+ formula.setAttribute('data-tex-snapshot', 'old snapshot');
+ f.context.openFormulaModal('inline', formula);
+ f.ids.formulaInput.value = 'x + 1';
+ f.context.insertFormulaFromInput();
+ assert.equal(f.ids.editorArea.childNodes.length, 1);
+ const updated = f.ids.editorArea.childNodes[0];
+ assert.equal(updated.getAttribute('data-formula'), 'x + 1');
+ assert.equal(updated.getAttribute('data-delimiter'), String.raw`\(`);
+ assert.equal(updated.textContent, String.raw`\(x + 1\)`);
+ assert.equal(updated.getAttribute('contenteditable'), 'false');
+ assert.equal(updated.getAttribute('data-tex-source'), null);
+ assert.equal(updated.getAttribute('data-tex-snapshot'), null);
+ assert.equal(f.calls.saves, 1);
+});
+
+test('confirming an unchanged formula preserves its exact source and node', () => {
+ const f = fixture();
+ const formula = f.formula(' x + y ');
+ formula.setAttribute('data-tex-source', '$ x + y $');
+ f.context.openFormulaModal('inline', formula);
+ f.context.insertFormulaFromInput();
+ assert.equal(f.ids.editorArea.childNodes[0], formula);
+ assert.equal(formula.getAttribute('data-tex-source'), '$ x + y $');
+ assert.equal(f.calls.saves, 0);
+});
+
+test('changing formula mode resets the delimiter while keeping nested markup valid', () => {
+ const f = fixture();
+ const formula = f.formula('x');
+ formula.setAttribute('data-delimiter', String.raw`\(`);
+ f.context.openFormulaModal('inline', formula);
+ f.radios.find(r => r.value === 'block').checked = true;
+ f.context.insertFormulaFromInput();
+ const updated = f.ids.editorArea.childNodes[0];
+ assert.equal(updated.tagName, 'SPAN');
+ assert.equal(updated.className, 'formula-block');
+ assert.equal(updated.getAttribute('data-delimiter'), '$$');
+});
+
+test('closing a formula editor cancels edits and a later insertion does not replace it', () => {
+ const f = fixture();
+ const original = f.formula('x');
+ f.context.openFormulaModal('inline', original);
+ f.ids.formulaInput.value = 'discarded';
+ f.context.closeFormulaModal();
+ f.context.openFormulaModal('inline');
+ assert.equal(f.ids.formulaInput.value, '');
+ f.ids.formulaInput.value = 'y';
+ f.context.insertFormulaFromInput();
+ assert.equal(f.ids.editorArea.childNodes[0], original);
+ assert.equal(original.getAttribute('data-formula'), 'x');
+ assert.equal(f.ids.editorArea.childNodes[1].getAttribute('data-formula'), 'y');
+});
+
+test('new display formulas use a paragraph-safe span', () => {
+ const f = fixture();
+ f.context.insertFormulaNode('x+y', 'block');
+ const formula = f.ids.editorArea.childNodes[0];
+ assert.equal(formula.tagName, 'SPAN');
+ assert.equal(formula.className, 'formula-block');
+ assert.equal(formula.getAttribute('data-delimiter'), '$$');
+});
+
+test('editing a legacy block formula does not remove its equation numbering', () => {
+ const f = fixture();
+ const formula = f.formula('x', 'block');
+ formula.removeAttribute('data-delimiter');
+ f.context.openFormulaModal('block', formula);
+ f.ids.formulaInput.value = 'y';
+ f.context.insertFormulaFromInput();
+ assert.equal(f.ids.editorArea.childNodes[0].getAttribute('data-delimiter'), 'equation');
+});
diff --git a/tests/export-regression.test.mjs b/tests/export-regression.test.mjs
new file mode 100644
index 0000000..75ef548
--- /dev/null
+++ b/tests/export-regression.test.mjs
@@ -0,0 +1,207 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import test from 'node:test';
+
+const source = fs.readFileSync(new URL('../main.js', import.meta.url), 'utf8');
+const context = vm.createContext({document: {addEventListener() {}}, Node: {TEXT_NODE: 3, ELEMENT_NODE: 1}});
+vm.runInContext(source, context);
+
+// Minimal DOM model for the serializer. These tests do not claim browser layout coverage.
+const html = value => String(value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+const T = value => ({nodeType: 3, textContent: value});
+function E(tag, children = [], attributes = {}) {
+ const attrs = new Map(Object.entries(attributes));
+ const node = {
+ nodeType: 1, tagName: tag.toUpperCase(), childNodes: children,
+ get attributes() { return Array.from(attrs, ([name, value]) => ({name, value})); },
+ getAttribute(name) { return attrs.get(name) ?? null; },
+ setAttribute(name, value) { attrs.set(name, String(value)); },
+ removeAttribute(name) { attrs.delete(name); },
+ classList: {contains(name) { return (attrs.get('class') || '').split(/\s+/).includes(name); }},
+ get textContent() { return this.childNodes.map(child => child.textContent).join(''); },
+ get innerHTML() { return this.childNodes.map(child => child.nodeType === 3 ? html(child.textContent) : child.outerHTML).join(''); },
+ get outerHTML() {
+ const name = this.tagName.toLowerCase();
+ const start = '<' + name + Array.from(attrs, ([key, value]) => ' ' + key + '="' + html(value) + '"').join('') + '>';
+ return /^(img|br)$/.test(name) ? start : start + this.innerHTML + '' + name + '>';
+ },
+ };
+ node.style = {
+ get textAlign() { return /(?:^|;)\s*text-align:\s*([^;]+)/.exec(attrs.get('style') || '')?.[1] || ''; },
+ set textAlign(value) { attrs.set('style', 'text-align: ' + value + ';'); },
+ };
+ return node;
+}
+function imported(node, tex) {
+ node.setAttribute('data-tex-source', tex);
+ node.setAttribute('data-tex-snapshot', context.getTexNodeSnapshot(node));
+ return node;
+}
+const convert = node => context.convertNodeToLatex(node);
+const formula = (tex, delimiter, block = false) => E(block ? 'div' : 'span', [T(tex)], {
+ class: block ? 'formula-block' : 'formula-inline', 'data-formula': tex, 'data-delimiter': delimiter,
+});
+
+test('untouched imported nodes retain every byte, including CRLF and comments', () => {
+ const original = '\r\n\\section{Title} % keep this\r\n';
+ assert.equal(convert(imported(E('h1', [T('Title')]), original)), original);
+ const gap = imported(E('span', [], {class: 'tex-whitespace', hidden: ''}), '\r\n\t ');
+ assert.equal(convert(gap), '\r\n\t ');
+});
+
+test('editing a paragraph preserves untouched inline TeX tokens and source of other roots', () => {
+ const command = imported(E('span', [T('\\%')], {class: 'tex-cmd'}), '\\%');
+ const bold = imported(E('b', [T('bold')]), '\\textbf {bold}');
+ const paragraph = imported(E('p', [T('Rate 50'), command, T(' and '), bold]), 'Rate 50\\% and \\textbf {bold}');
+ paragraph.childNodes[0].textContent = 'Rate 60';
+ assert.equal(convert(paragraph), 'Rate 60\\% and \\textbf {bold}');
+ const listingSource = '\\begin{lstlisting}\r\nx = 10 % 3;\r\n\\end{lstlisting}';
+ const listing = imported(E('div', [T(listingSource)], {class: 'raw-tex'}), listingSource);
+ assert.equal(convert(listing), listingSource);
+});
+
+test('root formatting and tag changes invalidate original-source snapshot', () => {
+ const paragraph = imported(E('p', [T('Text')]), 'Text');
+ paragraph.style.textAlign = 'center';
+ assert.match(convert(paragraph), /^\\begin\{center\}/);
+ const heading = imported(E('h1', [T('Title')]), '\\section{Title}');
+ heading.tagName = 'H2';
+ assert.equal(convert(heading), '\\subsection{Title}');
+});
+
+test('changing formula attributes invalidates original-source snapshot', () => {
+ const node = imported(formula('x', '$'), '$x$');
+ node.setAttribute('data-formula', 'y');
+ assert.equal(convert(node), '$y$');
+});
+
+test('preservation metadata does not invalidate parent snapshots or grow recursively', () => {
+ const child = E('b', [T('bold')]);
+ const parent = imported(E('p', [child]), '\\textbf {bold}');
+ const before = context.getTexNodeSnapshot(parent);
+ imported(child, '\\textbf {bold}');
+ assert.equal(context.getTexNodeSnapshot(parent), before);
+ assert.equal(convert(parent), '\\textbf {bold}');
+ child.childNodes[0].textContent = 'changed';
+ assert.notEqual(context.getTexNodeSnapshot(parent), before);
+ assert.equal(convert(parent), '\\textbf{changed}');
+});
+
+test('formulas retain math delimiters at root and inside a paragraph', () => {
+ for (const [delimiter, expected, block] of [
+ ['$', '$x^2$', false], ['$$', '$$x^2$$', true],
+ ['\\(', '\\(x^2\\)', false], ['\\[', '\\[x^2\\]', true],
+ ]) {
+ assert.equal(convert(formula('x^2', delimiter, block)), expected);
+ assert.equal(convert(E('p', [T('before '), formula('x^2', delimiter, block), T(' after')])), 'before ' + expected + ' after\n\n');
+ }
+});
+
+test('paragraph images are exported, preserve captions, and do not introduce float H dependency', () => {
+ const node = E('p', [T('Before '), E('img', [], {
+ src: 'figure.png', 'data-caption': 'Rate 50% & value_1', 'data-label': 'fig:test',
+ }), T(' after')]);
+ const tex = convert(node);
+ assert.match(tex, /\\begin\{figure\}\[htbp\]/);
+ assert.match(tex, /\\includegraphics\[width=0\.6\\textwidth\]\{figure\.png\}/);
+ assert.ok(tex.includes(String.raw`\caption{Rate 50\% \& value\_1}`));
+ assert.ok(tex.endsWith(' after\n\n'));
+ assert.ok(!tex.includes('[H]'));
+});
+
+test('edited imported image preserves requested placement and TeX caption', () => {
+ const image = imported(E('img', [], {
+ src: 'old.png', 'data-caption': '\\textbf{Caption}', 'data-caption-tex': 'true',
+ 'data-placement': 't', 'data-width': '0.3',
+ }), '\\begin{figure}[t]...\\end{figure}');
+ image.setAttribute('src', 'new.png');
+ const tex = convert(image);
+ assert.match(tex, /\\begin\{figure\}\[t\]/);
+ assert.ok(tex.includes('\\caption{\\textbf{Caption}}'));
+ assert.ok(tex.includes('{new.png}'));
+ assert.ok(!tex.includes('fig:image'));
+});
+
+test('line breaks and nested paragraphs are not flattened', () => {
+ assert.equal(convert(E('p', [T('first'), E('br'), T('second')])), 'first\\\\\nsecond\n\n');
+ assert.equal(convert(E('div', [E('p', [T('first')]), E('p', [T('second')])])), 'first\n\nsecond\n\n\n\n');
+});
+
+test('splitting an imported paragraph inserts a real paragraph break even if attributes are cloned', () => {
+ const first = imported(E('p', [T('first second')]), 'first second');
+ const second = E('p', [T('second')], Object.fromEntries(first.attributes.map(attr => [attr.name, attr.value])));
+ first.childNodes[0].textContent = 'first';
+ assert.equal(context.convertEditorNodesToLatex([first, second]), 'first\n\nsecond');
+ const newParagraph = E('div', [T('second')]);
+ assert.equal(context.convertEditorNodesToLatex([first, newParagraph]), 'first\n\nsecond\n\n');
+});
+
+test('existing source gaps and unchanged neighboring roots are not reformatted', () => {
+ const first = imported(E('p', [T('first')]), 'first');
+ const second = imported(E('p', [T('second')]), 'second');
+ const gap = imported(E('span', [], {class: 'tex-whitespace', hidden: ''}), '\r\n\r\n');
+ assert.equal(context.convertEditorNodesToLatex([first, gap, second]), 'first\r\n\r\nsecond');
+ assert.equal(context.convertEditorNodesToLatex([first, second]), 'firstsecond');
+});
+
+test('ordinary text is escaped consistently in headings, bold, italic, and list items', () => {
+ const text = '100% & value_1 # {x} $ ~ ^ \\';
+ const escaped = String.raw`100\% \& value\_1 \# \{x\} \$ \textasciitilde{} \textasciicircum{} \textbackslash{}`;
+ assert.equal(convert(E('h1', [T(text)])), '\\section{' + escaped + '}\n\n');
+ assert.equal(convert(E('b', [T(text)])), '\\textbf{' + escaped + '}');
+ assert.equal(convert(E('i', [T(text)])), '\\textit{' + escaped + '}');
+ assert.ok(convert(E('ul', [E('li', [T(text)])])).includes('\\item ' + escaped));
+});
+
+test('nested formatting, formulas, commands, and nested lists retain their semantics', () => {
+ const list = E('ul', [E('li', [E('b', [T('bold '), E('i', [T('italic')])]), T(' '),
+ formula('x_1', '$'), E('span', [T('\\cite{ref}')], {class: 'tex-cmd'}),
+ E('ol', [E('li', [T('nested')])]),
+ ])]);
+ const tex = convert(list);
+ assert.ok(tex.includes('\\textbf{bold \\textit{italic}}'));
+ assert.ok(tex.includes('$x_1$\\cite{ref}'));
+ assert.ok(tex.includes('\\begin{enumerate}'));
+ assert.ok(tex.includes('\\item nested'));
+});
+
+test('editing an imported emph body retains the emph command', () => {
+ const node = imported(E('em', [T('old')], {'data-tex-command': 'emph'}), '\\emph{old}');
+ node.childNodes[0].textContent = 'new';
+ assert.equal(convert(node), '\\emph{new}');
+});
+
+test('appending text after an imported hidden comment cannot swallow the new text', () => {
+ const comment = imported(E('span', [T('% keep')], {class: 'tex-cmd', hidden: ''}), '% keep');
+ const paragraph = imported(E('p', [T('Text '), comment]), 'Text % keep');
+ assert.equal(convert(paragraph), 'Text % keep');
+ paragraph.childNodes.push(T(' appended'));
+ assert.equal(convert(paragraph), 'Text % keep\n appended');
+ const terminated = E('p', [comment, T('\nnext line')]);
+ assert.equal(convert(terminated), '% keep\nnext line\n\n');
+});
+
+test('new formatting around a terminal comment does not comment out the closing brace', () => {
+ const comment = imported(E('span', [T('% keep')], {class: 'tex-cmd', hidden: ''}), '% keep');
+ assert.equal(convert(E('b', [T('Text '), comment])), '\\textbf{Text % keep\n}');
+});
+
+test('editing simple imported figures preserves absent width, centering, and empty captions', () => {
+ const image = imported(E('img', [], {
+ src: 'old.png', 'data-width': '', 'data-centering': 'false', 'data-placement': '',
+ 'data-caption': '', 'data-caption-tex': 'true', 'data-has-caption': 'true',
+ }), '\\begin{figure}\\includegraphics{old.png}\\caption{}\\end{figure}');
+ image.setAttribute('src', 'new.png');
+ const tex = convert(image);
+ assert.ok(tex.includes('\\includegraphics{new.png}'));
+ assert.ok(!tex.includes('width='));
+ assert.ok(!tex.includes('\\centering'));
+ assert.ok(tex.includes('\\caption{}'));
+});
+
+test('raw-block editing preserves existing text and contenteditable line breaks', () => {
+ const raw = E('div', [T('first'), E('div', [T('second')]), E('div', [E('br')]), E('div', [T('last')])], {class: 'raw-tex'});
+ assert.equal(convert(raw), 'first\nsecond\n\nlast');
+ assert.equal(convert(E('div', [T('x = 10 % 3;\r\n')], {class: 'raw-tex'})), 'x = 10 % 3;\r\n');
+});
diff --git a/tests/helpers/dom.mjs b/tests/helpers/dom.mjs
new file mode 100644
index 0000000..40287f2
--- /dev/null
+++ b/tests/helpers/dom.mjs
@@ -0,0 +1,151 @@
+// A small deterministic DOM fixture for generated editor markup, NOT a browser
+// engine. It models entity decoding, HTML newline normalization, tree traversal,
+// inert templates and mutation/serialization; it does not execute scripts, load
+// resources, calculate layout or implement HTML error-recovery rules.
+const voidTags = new Set(['BR', 'IMG', 'INPUT', 'HR', 'META', 'LINK', 'EMBED']);
+function decode(value) {
+ return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (all, entity) => {
+ if (/^#x/i.test(entity)) return String.fromCodePoint(parseInt(entity.slice(2), 16));
+ if (entity[0] === '#') return String.fromCodePoint(parseInt(entity.slice(1), 10));
+ return ({amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0'})[entity.toLowerCase()] ?? all;
+ });
+}
+function textEscape(value) { return value.replace(/&/g, '&').replace(//g, '>'); }
+function attrEscape(value) { return textEscape(value).replace(/"/g, '"'); }
+
+class Parent {
+ constructor() { this.childNodes = []; }
+ appendChild(node) { node.remove?.(); node.parentNode = this; this.childNodes.push(node); return node; }
+ contains(node) { return node === this || this.childNodes.some(child => child === node || child.contains?.(node)); }
+ get textContent() { return this.childNodes.map(node => node.textContent).join(''); }
+ set textContent(value) { this.childNodes = []; if (value) this.appendChild(new Text(String(value))); }
+ get innerHTML() { return this.childNodes.map(serialize).join(''); }
+ set innerHTML(html) { this.childNodes = []; parseInto(this, String(html)); }
+ querySelectorAll(selector) {
+ const result = [];
+ const visit = parent => {
+ for (const child of parent.childNodes) {
+ if (child.nodeType !== 1) continue;
+ if (child.matches(selector)) result.push(child);
+ visit(child);
+ }
+ };
+ visit(this);
+ return result;
+ }
+ querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
+ addEventListener() {}
+ focus() {}
+}
+
+export class Text {
+ constructor(value) { this.nodeType = 3; this.textContent = value; this.parentNode = null; }
+ get parentElement() { return this.parentNode?.nodeType === 1 ? this.parentNode : null; }
+ remove() {
+ if (!this.parentNode) return;
+ this.parentNode.childNodes.splice(this.parentNode.childNodes.indexOf(this), 1);
+ this.parentNode = null;
+ }
+}
+
+export class Element extends Parent {
+ constructor(tag) {
+ super();
+ this.nodeType = 1;
+ this.tagName = tag.toUpperCase();
+ this._attributes = new Map();
+ this.classList = { contains: name => this.className.split(/\s+/).includes(name) };
+ const self = this;
+ this.style = {
+ get textAlign() { return (self.getAttribute('style') || '').match(/(?:^|;)\s*text-align\s*:\s*([^;]+)/i)?.[1].trim() || ''; },
+ set textAlign(value) { self.setAttribute('style', 'text-align: ' + value + ';'); },
+ };
+ }
+ get parentElement() { return this.parentNode?.nodeType === 1 ? this.parentNode : null; }
+ get attributes() { return Array.from(this._attributes, ([name, value]) => ({name, value})); }
+ setAttribute(name, value) { this._attributes.set(name.toLowerCase(), String(value)); }
+ getAttribute(name) { return this._attributes.get(name.toLowerCase()) ?? null; }
+ removeAttribute(name) { this._attributes.delete(name.toLowerCase()); }
+ get className() { return this.getAttribute('class') || ''; }
+ set className(value) { this.setAttribute('class', value); }
+ matches(selector) {
+ if (selector === '*') return true;
+ if (selector.startsWith('[')) return this.getAttribute(selector.slice(1, -1)) !== null;
+ if (selector.startsWith('.')) return this.classList.contains(selector.slice(1));
+ return this.tagName === selector.toUpperCase();
+ }
+ remove() { Text.prototype.remove.call(this); }
+ replaceWith(...nodes) {
+ const parent = this.parentNode;
+ if (!parent) return;
+ const position = parent.childNodes.indexOf(this);
+ for (const node of nodes) node.remove?.();
+ parent.childNodes.splice(position, 1, ...nodes);
+ for (const node of nodes) node.parentNode = parent;
+ this.parentNode = null;
+ }
+}
+
+class Fragment extends Parent { constructor() { super(); this.nodeType = 11; } }
+class Template extends Element {
+ constructor() { super('template'); this.content = new Fragment(); }
+ get innerHTML() { return this.content.innerHTML; }
+ set innerHTML(value) { this.content.innerHTML = value; }
+}
+function serialize(node) {
+ if (node.nodeType === 3) return textEscape(node.textContent);
+ const tag = node.tagName.toLowerCase();
+ const attrs = node.attributes.map(attr => ' ' + attr.name + '="' + attrEscape(attr.value) + '"').join('');
+ return '<' + tag + attrs + '>' + (voidTags.has(node.tagName) ? '' : node.innerHTML + '' + tag + '>');
+}
+
+function parseInto(root, markup) {
+ // HTML tokenization normalizes source newlines before character references.
+ const html = markup.replace(/\r\n?/g, '\n');
+ const stack = [root];
+ for (let i = 0; i < html.length;) {
+ if (html.startsWith('', i + 4);
+ i = end < 0 ? html.length : end + 3;
+ continue;
+ }
+ if (html[i] !== '<' || !/^<\/?[a-z]/i.test(html.slice(i))) {
+ let end = html.indexOf('<', i + 1);
+ if (end < 0) end = html.length;
+ stack.at(-1).appendChild(new Text(decode(html.slice(i, end))));
+ i = end;
+ continue;
+ }
+ let end = i + 1;
+ let quote = null;
+ for (; end < html.length; end++) {
+ if (quote) { if (html[end] === quote) quote = null; }
+ else if (html[end] === '"' || html[end] === "'") quote = html[end];
+ else if (html[end] === '>') break;
+ }
+ const token = html.slice(i + 1, end);
+ i = Math.min(end + 1, html.length);
+ if (token[0] === '/') {
+ const tag = token.slice(1).trim().toUpperCase();
+ for (let j = stack.length - 1; j > 0; j--) {
+ if (stack[j].tagName === tag) { stack.length = j; break; }
+ }
+ continue;
+ }
+ const match = token.match(/^([^\s/>]+)([\s\S]*)$/);
+ const node = new Element(match[1]);
+ for (const attr of match[2].matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
+ node.setAttribute(attr[1], decode(attr[2] ?? attr[3] ?? attr[4] ?? ''));
+ }
+ stack.at(-1).appendChild(node);
+ if (!voidTags.has(node.tagName) && !token.endsWith('/')) stack.push(node);
+ }
+}
+
+export function createDocument() {
+ return {
+ addEventListener() {},
+ createElement: tag => tag.toLowerCase() === 'template' ? new Template() : new Element(tag),
+ createTextNode: text => new Text(String(text)),
+ };
+}
diff --git a/tests/parser-regression.test.mjs b/tests/parser-regression.test.mjs
new file mode 100644
index 0000000..37c018d
--- /dev/null
+++ b/tests/parser-regression.test.mjs
@@ -0,0 +1,208 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+
+const source = fs.readFileSync(new URL('../main.js', import.meta.url), 'utf8');
+const context = vm.createContext({document: {addEventListener() {}}, TextDecoder});
+vm.runInContext(source, context);
+const parse = text => context.parseTexToHtml(text);
+
+test('UTF-8 file decoding preserves BOM and refuses invalid or NUL-containing input', () => {
+ const text = '\ufeff\\documentclass{article}\r\n中文';
+ assert.equal(context.decodeTexFile(new TextEncoder().encode(text)), text);
+ assert.throws(() => context.decodeTexFile(Uint8Array.from([0xff, 0xfe, 0x41])));
+ assert.throws(() => context.decodeTexFile(Uint8Array.from([0x41, 0x00])));
+});
+
+function decodeAttribute(text) {
+ return text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot);/gi, (_, entity) => {
+ if (entity[0] === '#') return String.fromCodePoint(entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1)));
+ return {amp: '&', lt: '<', gt: '>', quot: '"'}[entity];
+ });
+}
+
+// Read only generated tags and attributes. This is not a browser DOM substitute;
+// it checks parser token boundaries and that every original source byte is owned.
+function elements(html) {
+ const roots = [];
+ const all = [];
+ const stack = [];
+ for (const match of html.matchAll(/<(\/?)([a-z\d]+)([^>]*)>/gi)) {
+ const [, closing, tag, attrText] = match;
+ if (closing) { stack.pop(); continue; }
+ const attrs = {};
+ for (const attr of attrText.matchAll(/([^\s=]+)(?:="([^"]*)")?/g)) attrs[attr[1]] = decodeAttribute(attr[2] || '');
+ const node = {tag, attrs};
+ all.push(node);
+ if (!stack.length) roots.push(node);
+ if (tag !== 'img' && tag !== 'br') stack.push(node);
+ }
+ return {roots, all};
+}
+
+function assertSourceCoverage(input) {
+ const html = parse(input);
+ const {roots} = elements(html);
+ for (const root of roots) assert.ok(Object.hasOwn(root.attrs, 'data-tex-source'), 'every root must retain source');
+ assert.equal(roots.map(n => n.attrs['data-tex-source']).join(''), input);
+ return {html, ...elements(html)};
+}
+
+test('HTML-looking TeX is text, never active HTML', () => {
+ for (const input of [
+ '
',
+ '',
+ String.raw`\section{
} Tail`,
+ String.raw`\begin{lstlisting}\end{lstlisting}`,
+ ]) {
+ const {all} = assertSourceCoverage(input);
+ assert.ok(all.every(node => !['img', 'script', 'iframe', 'svg'].includes(node.tag)));
+ assert.ok(all.every(node => Object.keys(node.attrs).every(name => !/^on/i.test(name))));
+ }
+});
+
+test('figure attribute contents cannot create event handlers', () => {
+ const input = String.raw`\begin{figure}\includegraphics{x" onerror="alert(1)}\caption{