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** [![Version](https://img.shields.io/badge/Version-v2.0.2-6f42c1)](https://github.com/Escap1ng/VisualLaTeX/releases) [![License](https://img.shields.io/badge/License-Apache_2.0-0366d6)](./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 轻编辑器** [![Version](https://img.shields.io/badge/Version-v2.0.2-6f42c1)](https://github.com/Escap1ng/VisualLaTeX/releases) [![License](https://img.shields.io/badge/License-Apache_2.0-0366d6)](./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

三步上手

  1. 点击工具栏的「导入 .tex」,选择本地的 LaTeX 文件
  2. -
  3. 在左侧像使用 Word 一样修改文字、标题、列表与公式
  4. +
  5. 在左侧修改文字、标题与列表,双击已有公式可打开编辑
  6. 点击「下载文件」导出修改后的 .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 - // (文本已转义,此处无需再次转义,避免 & 变成 &amp;) - 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' + escapeAttr(caption) + '\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 + ''; +} - 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 + ''; + }, + }; + 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 + ''); +} + +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{}\end{figure}`; + const {all} = assertSourceCoverage(input); + assert.ok(all.every(node => Object.keys(node.attrs).every(name => !/^on/i.test(name)))); + assert.equal(all.filter(node => node.tag === 'svg').length, 0); +}); + +test('heading consumes balanced argument only, retaining following label/body', () => { + const input = String.raw`\section{A \textbf{bold} title}\label{sec:intro} First sentence.`; + const {roots, html} = assertSourceCoverage(input); + assert.equal(roots[0].tag, 'h1'); + assert.equal(roots[0].attrs['data-tex-source'], String.raw`\section{A \textbf{bold} title}`); + assert.ok(html.includes(' title')); + assert.ok(roots.some(n => n.attrs['data-tex-source'].includes('First sentence.'))); +}); + +test('starred/short headings and unsupported commands keep complete source', () => { + for (const input of [String.raw`\section*[Short]{A {nested} title} tail`, String.raw`\custom[opt]{a{b}c}{d} after`]) { + assertSourceCoverage(input); + } +}); + +test('literal environments retain percent signs, HTML, and fake nested commands', () => { + for (const name of ['lstlisting', 'verbatim', 'Verbatim', 'minted']) { + const input = '\\begin{' + name + '}\r\nx = 10 % 3; \\begin{figure} $fake$\r\n\\end{' + name + '}'; + const {roots, html} = assertSourceCoverage(input); + assert.equal(roots.length, 1); + assert.equal(roots[0].attrs.class, 'raw-tex'); + assert.ok(html.includes('10 % 3;')); + assert.ok(!html.includes('class="formula-inline"')); + } +}); + +test('unknown outer environments are isolated before any inner conversion', () => { + const input = String.raw`\begin{abstract} +\begin{equation}x=1\end{equation} +\end{abstract}`; + const {roots, all} = assertSourceCoverage(input); + assert.equal(roots.length, 1); + assert.equal(all.length, 1); + assert.equal(roots[0].attrs.class, 'raw-tex'); +}); + +test('same-name nested and malformed environments are preserved as complete raw units', () => { + for (const input of [ + String.raw`\begin{itemize}\item Outer\begin{itemize}\item Inner\end{itemize}\end{itemize}`, + String.raw`\begin{custom}missing end`, + String.raw`\begin{custom}\begin{other}mismatch\end{custom}`, + ]) { + const {roots} = assertSourceCoverage(input); + assert.equal(roots[0].attrs.class, 'raw-tex'); + } +}); + +test('multiple images and unsupported figure options remain raw without omissions', () => { + for (const input of [ + String.raw`\begin{figure}[t]\includegraphics[width=.3\textwidth]{a.png}\includegraphics[width=.4\textwidth]{b.png}\caption{Both}\end{figure}`, + String.raw`\begin{figure}\includegraphics[angle=90]{a.png}\caption{Rotated}\end{figure}`, + String.raw`\begin{figure}\includegraphics{https://example.invalid/tracker}\end{figure}`, + ]) { + const {roots, all} = assertSourceCoverage(input); + assert.equal(roots[0].attrs.class, 'raw-tex'); + assert.ok(all.every(node => node.tag !== 'img')); + } +}); + +test('simple single-image figure retains the visual path and original parameters', () => { + const input = String.raw`\begin{figure}[t] +\centering +\includegraphics[width=.3\textwidth]{a.png} +\caption{A \textbf{nested} caption}\label{fig:a} +\end{figure}`; + const {roots} = assertSourceCoverage(input); + assert.equal(roots[0].tag, 'img'); + assert.equal(roots[0].attrs['data-width'], '.3'); + assert.equal(roots[0].attrs['data-placement'], 't'); + assert.equal(roots[0].attrs['data-caption'], String.raw`A \textbf{nested} caption`); +}); + +test('optional-label lists stay raw; basic lists stay visual', () => { + for (const name of ['itemize', 'enumerate']) { + const optional = '\\begin{' + name + '}\\item[Important] Keep this\\end{' + name + '}'; + const {roots} = assertSourceCoverage(optional); + assert.equal(roots[0].attrs.class, 'raw-tex'); + const basic = '\\begin{' + name + '}\n\\item One\n\\item Two\n\\end{' + name + '}'; + const parsed = assertSourceCoverage(basic); + assert.equal(parsed.roots[0].tag, name === 'itemize' ? 'ul' : 'ol'); + assert.equal(parsed.all.filter(node => node.tag === 'li').length, 2); + } +}); + +test('all supported math delimiters remain distinct and escaped currency is not math', () => { + const input = String.raw`A $x$ B $$x^2$$ C \(y\) D \[z\] E \$5 and \$10.`; + const {all} = assertSourceCoverage(input); + const formulas = all.filter(n => /formula-/.test(n.attrs.class || '')); + assert.deepEqual(formulas.map(n => n.attrs['data-delimiter']), ['$', '$$', '\\(', '\\[']); + assert.deepEqual(formulas.map(n => n.attrs['data-formula']), ['x', 'x^2', 'y', 'z']); + assert.equal(all.filter(n => n.attrs.class === 'tex-cmd' && n.attrs['data-tex-source'] === '\\$').length, 2); +}); + +test('existing escapes and nested formatting are protected TeX tokens', () => { + const input = String.raw`Rate 50\% and file\_name. \textbf{A \textit{nested} B} \cite[See]{key,{other}}`; + const {all} = assertSourceCoverage(input); + for (const token of ['\\%', '\\_', '\\cite[See]{key,{other}}']) { + assert.ok(all.some(n => n.attrs.class === 'tex-cmd' && n.attrs['data-tex-source'] === token)); + } + assert.ok(all.some(n => n.tag === 'b')); + assert.ok(all.some(n => n.tag === 'i')); +}); + +test('comments and CRLF whitespace are represented without source normalization', () => { + const input = ' \r\n% before\r\nHello% attached\r\nworld\r\n\r\n\\section{Next}\r\n\tTail \r\n'; + const {html, all} = assertSourceCoverage(input); + assert.ok(html.includes(' ')); + assert.ok(all.some(n => n.attrs.class === 'tex-cmd' && n.attrs.hidden === '' && n.attrs['data-tex-source'].startsWith('%'))); + assertSourceCoverage(' \r\n\t\r\n'); + assert.equal(parse(''), ''); +}); + +test('inline verbatim protects percent, math and fake environment tokens', () => { + for (const input of [ + String.raw`Before \verb|x%y $z$ \begin{figure}| after`, + String.raw`Before \verb*+a% \section{fake}+ after`, + String.raw`Before \lstinline[language=C]!10 % 3;! after`, + ]) { + const {all, roots} = assertSourceCoverage(input); + assert.equal(roots.length, 1); + assert.equal(roots[0].tag, 'p'); + assert.ok(all.some(n => n.attrs.class === 'tex-cmd' && /\\(?:verb|lstinline)/.test(n.attrs['data-tex-source']))); + assert.ok(all.every(n => !/formula-/.test(n.attrs.class || ''))); + assert.ok(all.every(n => !Object.hasOwn(n.attrs, 'hidden'))); + } + const env = String.raw`\begin{custom}\verb|\end{custom}| tail\end{custom}`; + const parsed = assertSourceCoverage(env); + assert.equal(parsed.roots.length, 1); + const malformed = 'Before \\verb|x%y\nvisible after'; + assertSourceCoverage(malformed); +}); + +test('line breaks retain optional length and starred variant on one token', () => { + const input = String.raw`A\\[2pt]B\\*[1ex]C`; + const {all} = assertSourceCoverage(input); + assert.deepEqual(all.filter(n => n.tag === 'br').map(n => n.attrs['data-tex-source']), ['\\\\[2pt]', '\\\\*[1ex]']); +}); diff --git a/tests/roundtrip-regression.test.mjs b/tests/roundtrip-regression.test.mjs new file mode 100644 index 0000000..6770c34 --- /dev/null +++ b/tests/roundtrip-regression.test.mjs @@ -0,0 +1,273 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { createDocument, Element } from './helpers/dom.mjs'; + +// These integration checks use real application functions with a minimal DOM +// fixture, not a browser engine. Browser execution and TeX compilation are separate. +const source = fs.readFileSync(new URL('../main.js', import.meta.url), 'utf8'); +function fixture(storage = new Map()) { + const document = createDocument(); + const editor = new Element('div'); + const preview = new Element('div'); + const status = new Element('span'); + const alerts = []; + const errors = []; + const documentEvents = {}; + const windowEvents = {}; + const controls = { failWrites: false, confirm: true, reloads: 0 }; + document.addEventListener = (type, handler) => { documentEvents[type] = handler; }; + document.getElementById = id => ({ editorArea: editor, previewArea: preview, statusText: status })[id]; + const context = vm.createContext({ + document, Node: { ELEMENT_NODE: 1, TEXT_NODE: 3 }, Date, + console: { error: (...args) => errors.push(args) }, + alert: message => alerts.push(message), + confirm: () => controls.confirm, + clearTimeout() {}, setTimeout() { return 1; }, + location: { reload() { controls.reloads++; } }, + window: { addEventListener: (type, handler) => { windowEvents[type] = handler; } }, + localStorage: { getItem: key => storage.get(key) ?? null, + setItem: (key, value) => { + if (controls.failWrites) throw new Error('QuotaExceededError'); + storage.set(key, String(value)); + }, removeItem: key => storage.delete(key) }, + __editor: editor, __preview: preview, __status: status, + }); + vm.runInContext(source, context); + vm.runInContext('editorArea=__editor;previewArea=__preview;statusText=__status;', context); + function initialize() { + // Run real startup and persistence callbacks while excluding unrelated UI setup. + vm.runInContext('initToolbar=()=>{};initPanelTabs=()=>{};initFormulaModal=()=>{};', context); + documentEvents.DOMContentLoaded(); + } + return { context, editor, preview, status, storage, alerts, errors, controls, windowEvents, initialize }; +} + +const documentWith = body => '\\documentclass{article}\n\\begin{document}' + body + '\\end{document}\n'; +const roundtripCases = [ + ['CRLF, comments, blank lines and tail', '\ufeff % leading comment\r\n\\documentclass{article}\r\n\r\n\\begin{document}\r\n\r\nText % keep this comment\r\nnext line\r\n\r\n\\end{document}\r\n% trailing comment\r\n '], + ['same-line heading and reference', documentWith(String.raw`\section{A \textbf{bold} title}\label{sec:intro} First sentence.`)], + ['multiple images and figure parameters', documentWith(String.raw` +\begin{figure}[t] +\includegraphics[width=.3\textwidth]{a.png} +\includegraphics[width=.4\textwidth]{b.png} +\caption{A \textbf{nested} caption} +\end{figure} +`)], + ['optional list labels', documentWith(String.raw`\begin{itemize}\item[Important] Keep this\end{itemize}`)], + ['literal code percent and HTML', documentWith(String.raw`\begin{lstlisting} +x = 10 % 3; + +\end{lstlisting}`)], + ['nested equation inside preserved environment', documentWith(String.raw`\begin{abstract} +\begin{equation}x=1\end{equation} +\end{abstract}`)], + ['all math delimiters and escaped text', documentWith(String.raw`Cost \$5, rate 50\%, file\_name. $x$ $$y$$ \(z\) \[w\]`)], + ['document markers inside comments, macro arguments and literal code', String.raw`% \begin{document} +\documentclass{article} +\newcommand{\sample}{\begin{document}} +\begin{document} +\begin{lstlisting} +literal \end{document} +\end{lstlisting} +\lstinline|\end{document}| and \verb|\begin{document}| +Done. +\end{document} +% tail`], +]; + +for (const [name, tex] of roundtripCases) { + test('import/export preserves exact source: ' + name, () => { + const f = fixture(); + f.context.applyImportedTex(tex, 'paper.tex'); + assert.deepEqual(f.alerts, []); + assert.equal(f.context.domToLatex(), tex); + assert.equal(f.preview.textContent, tex); + }); +} + +test('editing one paragraph does not modify adjacent raw environments or document framing', () => { + const raw = String.raw`\begin{figure}[t] +\includegraphics[width=.3\textwidth]{a.png} +\includegraphics[width=.4\textwidth]{b.png} +\caption{Keep \textbf{exactly}} +\end{figure}`; + const tex = documentWith('\nOriginal paragraph.\n\n' + raw + '\n\nLast paragraph.\n'); + const f = fixture(); + f.context.applyImportedTex(tex, 'paper.tex'); + const paragraph = f.editor.querySelectorAll('p').find(node => node.textContent === 'Original paragraph.'); + assert.ok(paragraph, 'the simple paragraph must remain visually editable'); + paragraph.textContent = 'Edited paragraph.'; + assert.equal(f.context.domToLatex(), tex.replace('Original paragraph.', 'Edited paragraph.')); +}); + +test('source-looking HTML remains text and cannot create active elements on import', () => { + const tex = documentWith('\n\n\n'); + const f = fixture(); + f.context.applyImportedTex(tex, 'untrusted.tex'); + assert.equal(f.editor.querySelectorAll('img').length, 0); + assert.equal(f.editor.querySelectorAll('script').length, 0); + assert.equal(f.context.domToLatex(), tex); +}); + +test('editing text retains adjacent escaped characters, commands and math delimiters', () => { + const tex = documentWith('\n' + String.raw`Rate 50\%, file\_name, \cite{key}, $$x^2$$ and \(y\).` + '\n'); + const f = fixture(); + f.context.applyImportedTex(tex, 'paper.tex'); + const paragraph = f.editor.querySelector('p'); + const firstText = paragraph.childNodes.find(node => node.nodeType === 3 && node.textContent.startsWith('Rate ')); + assert.ok(firstText); + firstText.textContent = firstText.textContent.replace('Rate ', 'Updated rate '); + assert.equal(f.context.domToLatex(), tex.replace('Rate ', 'Updated rate ')); +}); + +test('empty document and metadata survive JSON draft save/reload', () => { + const tex = '% prefix\r\n\\documentclass{report}\r\n\\begin {document}\\end {document}\r\n% tail\r\n'; + const f = fixture(); + f.context.applyImportedTex(tex, 'empty.tex'); + assert.equal(f.editor.innerHTML, ''); + const saved = JSON.parse(f.storage.get('visuellatex_document')); + assert.equal(saved.html, ''); + assert.equal(saved.filename, 'empty.tex'); + const reloaded = fixture(f.storage); + reloaded.editor.innerHTML = '

    Welcome

    '; + reloaded.context.loadDraft(); + assert.deepEqual(reloaded.errors, []); + assert.equal(reloaded.editor.innerHTML, ''); + assert.equal(vm.runInContext('importedFileName', reloaded.context), 'empty.tex'); + assert.equal(reloaded.context.domToLatex(), tex); +}); + +test('nonempty draft restoration preserves source snapshots and command semantics', () => { + const tex = documentWith('\r\nText ' + String.raw`\emph{emphasis} and \textbf{bold} plus $$x^2$$.` + '\r\n'); + const f = fixture(); + f.context.applyImportedTex(tex, 'styled.tex'); + const reloaded = fixture(f.storage); + reloaded.context.loadDraft(); + assert.deepEqual(reloaded.errors, []); + assert.equal(reloaded.context.domToLatex(), tex); +}); + +test('legacy drafts strip event handlers, active elements and unsafe image protocols', () => { + const f = fixture(new Map([['visuellatex_draft', + '

    Keep bold

    ' + + '']])); + f.context.loadDraft(); + assert.deepEqual(f.errors, []); + assert.equal(f.editor.querySelectorAll('script').length, 0); + assert.equal(f.editor.querySelectorAll('iframe').length, 0); + assert.equal(f.editor.querySelector('p').getAttribute('onclick'), null); + assert.equal(f.editor.querySelector('img').getAttribute('onerror'), null); + assert.equal(f.editor.querySelector('img').getAttribute('src'), null); + assert.equal(f.editor.querySelector('b').textContent, 'bold'); +}); + +test('legacy trimmed comments cannot swallow document boundaries after migration', () => { + const preamble = '\\documentclass{article}\n% trailing preamble comment'; + const f = fixture(new Map([ + ['visuellatex_draft', '
    % trailing body comment
    '], + ['visuellatex_preamble', preamble], + ['visuellatex_tail', '% trailing file comment'], + ['visuellatex_filename', 'legacy.tex'], + ])); + f.context.loadDraft(); + assert.deepEqual(f.errors, []); + const expected = preamble + '\n\n\\begin{document}\n\n' + + '% trailing body comment\n\\end{document}\n% trailing file comment\n'; + assert.equal(f.context.domToLatex(), expected); + assert.equal(vm.runInContext('importedFileName', f.context), 'legacy.tex'); + + // Once migrated to JSON, restoring again must not append boundaries twice. + assert.equal(f.context.saveDraft(), true); + const reloaded = fixture(f.storage); + reloaded.context.loadDraft(); + assert.deepEqual(reloaded.errors, []); + assert.equal(reloaded.context.domToLatex(), expected); +}); + +test('draft sanitizer retains safe alignment and removes unrelated CSS', () => { + const f = fixture(); + f.editor.innerHTML = f.context.sanitizeDraftHtml('

    Keep

    '); + assert.equal(f.editor.querySelector('p').style.textAlign, 'center'); + assert.equal(f.editor.querySelector('p').getAttribute('style'), 'text-align: center;'); +}); + +for (const [name, payload] of [ + ['corrupted JSON', '{broken draft'], + ['unknown draft version', JSON.stringify({version: 99, html: '

    Keep this future draft

    '})], +]) { + test(name + ' survives flush and the registered pagehide callback', () => { + const f = fixture(new Map([['visuellatex_document', payload]])); + f.initialize(); + assert.equal(f.errors.length, 1); + f.editor.innerHTML = '

    Unrestored placeholder

    '; + f.context.flushDraft(); + assert.equal(f.storage.get('visuellatex_document'), payload); + assert.equal(typeof f.windowEvents.pagehide, 'function'); + f.windowEvents.pagehide(); + assert.equal(f.storage.get('visuellatex_document'), payload); + }); + + for (const action of ['import', 'new']) { + test(name + ' can be deliberately replaced by explicit ' + action, () => { + const f = fixture(new Map([['visuellatex_document', payload]])); + f.context.loadDraft(); + if (action === 'import') f.context.applyImportedTex(documentWith('\nReplacement text.\n'), 'replacement.tex'); + else f.context.startNewDocument(); + const saved = JSON.parse(f.storage.get('visuellatex_document')); + assert.equal(saved.version, 1); + if (action === 'import') assert.equal(saved.filename, 'replacement.tex'); + else assert.equal(saved.filename, null); + f.editor.innerHTML = '

    Further edit

    '; + f.context.flushDraft(); + assert.match(JSON.parse(f.storage.get('visuellatex_document')).html, /Further edit/); + }); + } +} + +test('clearing and reloading a draft prevents pagehide from recreating the removed data', () => { + const keys = ['visuellatex_document', 'visuellatex_draft', 'visuellatex_draft_time', + 'visuellatex_preamble', 'visuellatex_tail', 'visuellatex_filename', 'modelpaper_draft', 'modelpaper_draft_time']; + const f = fixture(); + f.context.applyImportedTex(documentWith('\nDiscard this draft.\n'), 'discard.tex'); + f.initialize(); + for (const key of keys.slice(1)) f.storage.set(key, 'legacy value'); + f.context.reloadAndClearDraft(); + assert.equal(f.controls.reloads, 1); + for (const key of keys) assert.equal(f.storage.has(key), false, key + ' should be removed'); + f.context.flushDraft(); + f.windowEvents.pagehide(); + for (const key of keys) assert.equal(f.storage.has(key), false, key + ' should stay removed'); +}); + +test('quota failure retains the previous atomic draft and displays a saving error', () => { + const f = fixture(); + f.context.applyImportedTex(documentWith('\nPrevious saved text.\n'), 'paper.tex'); + const previousPayload = f.storage.get('visuellatex_document'); + f.editor.innerHTML = '

    Unsaved newer content

    '; + f.controls.failWrites = true; + assert.equal(f.context.saveDraft(), false); + assert.equal(f.storage.get('visuellatex_document'), previousPayload); + assert.match(f.status.textContent, /保存失败/); + assert.equal(f.errors.length, 1); +}); + +test('inserting an image into an imported paragraph adds graphicx only when needed', () => { + const tex = '% exact prefix\n\\documentclass{article}\n\\begin{document}\nOriginal paragraph.\n\\end{document}\n'; + const f = fixture(); + f.context.applyImportedTex(tex, 'paper.tex'); + assert.equal(f.context.domToLatex(), tex, 'untouched import must not add a package or change its prefix'); + const image = new Element('img'); + image.setAttribute('src', 'figure.png'); + image.setAttribute('data-caption', 'A figure'); + image.setAttribute('data-label', 'fig:test'); + f.editor.querySelector('p').appendChild(image); + const exported = f.context.domToLatex(); + assert.ok(exported.startsWith('% exact prefix\n\\documentclass{article}\n\\usepackage{graphicx}\n')); + assert.match(exported, /\\includegraphics\[[^\]]*\]\{figure\.png\}/); + assert.match(exported, /\\begin\{figure\}\[htbp\]/); + assert.doesNotMatch(exported, /\\begin\{figure\}\[H\]/); + assert.doesNotMatch(exported, /\\usepackage\{float\}/); + assert.equal((exported.match(/\\usepackage\{graphicx\}/g) || []).length, 1); +});