diff --git a/.agents/notes/implemented/feature/2026-11-01-markdown-offline-chemistry-and-subscript.md b/.agents/notes/implemented/feature/2026-11-01-markdown-offline-chemistry-and-subscript.md new file mode 100644 index 000000000..96de4d430 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-01-markdown-offline-chemistry-and-subscript.md @@ -0,0 +1,69 @@ +# Agent 笔记:Markdown 离线化学公式与下标 + +状态:已实现 + +## 先说结论 + +macOS 已有数学公式和 Mermaid 图表的离线引擎,不需要再造解析器或接在线服务。 +本次补上 KaTeX 的化学扩展 mhchem,使 `\ce{...}` 反应式和 `\pu{...}` 物理单位可用, +并启用 Comrak 现成的下标扩展。macOS PlantUML 已由独立改动接入,见 +[离线 PlantUML 预览](2026-11-01-macos-offline-plantuml.md);Windows 方言对齐等仍未完成, +不能因这些 macOS 增强而把 Issue #706 当作整体完成。 + +## 问题 + +KaTeX 是把 LaTeX 数学语法转换为网页公式的成熟库,但基础库不包含 mhchem, +后者是负责解析化学反应和单位的可选扩展。只打包基础库时,数学公式能显示, +`\ce{H2O}` 却报未定义命令。共享 Markdown 解析器 Comrak 已支持上下标, +此前只启用了上标,`H~2~O` 不能正确输出下标。 + +## 决策 + +- 从同版本 KaTeX 0.16.0 npm 分发包提取 mhchem,先验证整个归档的 SHA-512 完整性, + 再固定脚本 SHA-256,保留源文件中被压缩分发去掉的版权和适配声明。 +- 模板按 KaTeX、mhchem、预览脚本顺序加载。现有公式渲染器继续使用 `trust: false`, + 不接受公式引入脚本、网页或网络资源;运行时不下载任何依赖。 +- 开启 Comrak 的 `subscript`。单波浪线表示下标,双波浪线仍表示删除线, + 代码中的字面语法保持原文。正确例子是 `H~2~O`,不要用正则替换全文中的 `~`, + 否则代码、删除线和公式会被误改。 +- 共享输入加真实 WebKit 浏览器引擎回归,同时检查数学、化学、Mermaid 生成的 SVG、 + 脚注及回链,不能仅断言代码块带有语言类名就声称图表可用。 + +## 考虑过的备选方案 + +- 重新实现化学语法:已有与当前 KaTeX 完全匹配的成熟上游,不采用。 +- 运行时从 CDN 加载:离线失败且扩大网络和供应链边界,不采用。 +- 顺手升级 KaTeX、Mermaid 或把 Windows 解析器整体迁移:增加无关兼容性风险, + 本批只填已证实缺口,不把两个平台现状混为一致。 +- 直接接入在线 PlantUML 服务器:会把用户文档发送到第三方,也不能离线,不采用。 + PlantUML 的独立上游、分发和执行隔离由上述独立决策验证,不复用公式渲染的执行上下文。 + +## 后果 + +数学公式、化学反应、单位和上下标可以在无网络预览中共存,新增脚本与已有 KaTeX 版本绑定。 +代价是需要随 KaTeX 更新一起核对扩展、许可、来源哈希及真实引擎回归。 +GitHub 特有媒体及 Windows 方言对齐不是本批已交付能力;macOS PlantUML 的能力和限制以独立笔记为准。 + +这些模板和第三方脚本是 Git 输入,构建复制后再签名,运行时只读加载。 +渲染状态仅在内存和非持久 WebKit 中存活,不新增缓存、下载或安装目录写入; +资源复用清单显式排除整个 Markdown 预览资源,不跨工作树复制生成 bundle。 +这不改变代码签名或 Sparkle 增量更新所依赖的发布字节。 + +## 验证 + +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core project::markdown::tests`。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter MarkdownPreviewRichContentTests`: + 深浅主题使用生产 Rust bridge 和打包模板,检查公式与可访问数学标记 MathML、图表、脚注和代码, + 并比较运行前后全部预览文件 SHA-256,检查没有远程资源请求。 +- `node --test scripts/resources/test-reuse-worktree-resources.mjs`:拒绝跨工作树复制预览资源。 +- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`。 + +原生组件测试不代替完整应用操作验收,Windows 运行时未在本批验证。 + +## 适用范围 + +- `rust/lithe-core/src/project/markdown.rs` +- `macos/Sources/Lithe/Resources/MarkdownPreview/` +- `macos/Tests/LitheTests/MarkdownPreviewRichContentTests.swift` +- `shared/fixtures/editor/markdown-rich-content-v1.json` +- `scripts/resources/worktree-resources.json` diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 8ccddbf0b..fca45f68b 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -604,6 +604,13 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 以下目录不应直接复制或跨工作树共享: +- Markdown 离线预览的模板、脚本、样式、字体和许可来自 Git 跟踪的 + `macos/Sources/Lithe/Resources/MarkdownPreview/`。KaTeX 0.16.0 的 mhchem 扩展与现有 + KaTeX 版本匹配,npm 归档完整性及文件 SHA-256 固定在 `THIRD_PARTY_NOTICES.md`。 + 输入与平台架构、工具链无关;构建时从当前 checkout 复制进 bundle 后签名,运行时仅只读加载, + 不下载、不写回、不产生持久化渲染缓存,不影响签名和 Sparkle delta。生成 bundle 没有可靠的 + 跨 worktree identity stamp,`excludedResources.markdown-preview-assets` 走资源脚本的 + 通用拒绝路由,任何复制阶段都不得从其他工作树或安装 app 复用这些资源。 - macOS Markdown PlantUML:Git 中的 `MarkdownPreview/vendor/plantuml/`、 `plantuml.html` 与 `plantuml-renderer.js` 随当前 checkout 打包,来源为固定的 MIT `@plantuml/core` 1.2026.9,归档 SHA-512、引擎 SHA-256 和上游 revision 见 diff --git a/macos/Sources/Lithe/Resources/MarkdownPreview/THIRD_PARTY_NOTICES.md b/macos/Sources/Lithe/Resources/MarkdownPreview/THIRD_PARTY_NOTICES.md index c117280d3..7a6c801a4 100644 --- a/macos/Sources/Lithe/Resources/MarkdownPreview/THIRD_PARTY_NOTICES.md +++ b/macos/Sources/Lithe/Resources/MarkdownPreview/THIRD_PARTY_NOTICES.md @@ -4,6 +4,7 @@ All preview dependencies are bundled with Lithe and loaded from the application - Highlight.js 11.5.1 — BSD-3-Clause — `vendor/highlight-LICENSE` - KaTeX 0.16.0 and its fonts — MIT — `vendor/katex-LICENSE` +- KaTeX 0.16.0 mhchem extension (mhchem 3.3.0 adaptation) — MIT / upstream Apache-2.0 notice — `vendor/mhchem-NOTICE` - Mermaid 11.14.0 — MIT — `vendor/mermaid-LICENSE` - D3 6.7.0 — ISC — `vendor/d3-LICENSE` - markmap-lib 0.14.1 — MIT — `vendor/markmap-lib-LICENSE` @@ -12,6 +13,8 @@ All preview dependencies are bundled with Lithe and loaded from the application The Highlight.js browser bundle is the distribution already vendored by MiaoYan; its version banner and upstream license are preserved. The remaining files come from the matching npm distributions. +`vendor/mhchem.min.js` comes from `https://registry.npmjs.org/katex/-/katex-0.16.0.tgz` (`package/dist/contrib/mhchem.min.js`), matching the bundled KaTeX runtime. The archive is verified with npm's SHA-512 integrity `sha512-wPRB4iUPysfH97wTgG5/tRLYxmKVq6Q4jRAWRVOUxXB1dsiv4cvcNjqabHkrOvJHM1Bpk3WrgmllSO1vIvP24w==`; the extracted file's SHA-256 is `5c0a121a8b490afc85860a522347aeb34fb508c6b23044e5d29f6b2194227b51`. The source copyright and adaptation notice are preserved separately because the minified distribution omits that header. This is a checked-in build input, never downloaded or modified at runtime. + PlantUML uses the official JavaScript build's built-in Smetana layout engine. Graphviz/Viz.js, the URL encoder, optional standard libraries, themes, emoji and external include loaders are not bundled. Rendering is local in a nonpersistent, restricted WebView; the result is displayed as an SVG image, never injected into the document DOM. `vendor/plantuml/plantuml.js` comes unchanged from `https://registry.npmjs.org/@plantuml/core/-/core-1.2026.9.tgz` (`package/plantuml.js`, upstream Git revision `18d0aae311a4f1eb3723bb4b0ad939185e22f166`). npm archive integrity: `sha512-Qy46og9hEb+wOFtjUfniWkl/BDwHpDuebQX5rF+35s9PS2G4cKZf1iWw4E/ELLQ0pTkESPDoQTbKWcc3ZyVcVA==`. Script SHA-256: `21c049ce47d087eff61ae0836f9c62a2b0c0d2528a5f16c2247057544228fae7`. The npm release's MIT license and upstream notices are preserved in `vendor/plantuml/LICENSE` (line endings normalized to LF only). Versions before 1.2026.6 use different licensing and must not be substituted. These are Git-tracked build inputs, not runtime downloads or writable bundle state. diff --git a/macos/Sources/Lithe/Resources/MarkdownPreview/index.html b/macos/Sources/Lithe/Resources/MarkdownPreview/index.html index 19e8eb08a..1d34c85e4 100644 --- a/macos/Sources/Lithe/Resources/MarkdownPreview/index.html +++ b/macos/Sources/Lithe/Resources/MarkdownPreview/index.html @@ -10,6 +10,7 @@ + diff --git a/macos/Sources/Lithe/Resources/MarkdownPreview/vendor/mhchem-NOTICE b/macos/Sources/Lithe/Resources/MarkdownPreview/vendor/mhchem-NOTICE new file mode 100644 index 000000000..96a2706ac --- /dev/null +++ b/macos/Sources/Lithe/Resources/MarkdownPreview/vendor/mhchem-NOTICE @@ -0,0 +1,279 @@ +KaTeX mhchem extension, distributed with KaTeX 0.16.0 + +Upstream source notice (preserved from contrib/mhchem/mhchem.js): + +/* eslint-disable */ +/* -*- Mode: Javascript; indent-tabs-mode:nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ + +/************************************************************* + * + * KaTeX mhchem.js + * + * This file implements a KaTeX version of mhchem version 3.3.0. + * It is adapted from MathJax/extensions/TeX/mhchem.js + * It differs from the MathJax version as follows: + * 1. The interface is changed so that it can be called from KaTeX, not MathJax. + * 2. \rlap and \llap are replaced with \mathrlap and \mathllap. + * 3. Four lines of code are edited in order to use \raisebox instead of \raise. + * 4. The reaction arrow code is simplified. All reaction arrows are rendered + * using KaTeX extensible arrows instead of building non-extensible arrows. + * 5. \tripledash vertical alignment is slightly adjusted. + * + * This code, as other KaTeX code, is released under the MIT license. + * + * /************************************************************* + * + * MathJax/extensions/TeX/mhchem.js + * + * Implements the \ce command for handling chemical formulas + * from the mhchem LaTeX package. + * + * --------------------------------------------------------------------- + * + * Copyright (c) 2011-2015 The MathJax Consortium + * Copyright (c) 2015-2018 Martin Hensel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +KaTeX license: + +The MIT License (MIT) + +Copyright (c) 2013-2020 Khan Academy and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Apache License 2.0 text for the upstream MathJax adaptation: + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or + Object form, that is based on (or derived from) the Work and + for which the editorial revisions, annotations, elaborations, + or other modifications represent, as a whole, an original work + of authorship. For the purposes of this License, Derivative Works + shall not include works that remain separable from, or merely link + (or bind by name) to the interfaces of, the Work and Derivative Works + thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of + its distribution, then any Derivative Works that You + distribute must include a readable copy of the attribution + notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative + Works, in at least one of the following places: within a + NOTICE text file distributed as part of the Derivative Works; + within the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as + a result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[ ]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/macos/Sources/Lithe/Resources/MarkdownPreview/vendor/mhchem.min.js b/macos/Sources/Lithe/Resources/MarkdownPreview/vendor/mhchem.min.js new file mode 100644 index 000000000..84e1198de --- /dev/null +++ b/macos/Sources/Lithe/Resources/MarkdownPreview/vendor/mhchem.min.js @@ -0,0 +1 @@ +!function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e(require("katex"));else if("function"==typeof define&&define.amd)define(["katex"],e);else{var n="object"==typeof exports?e(require("katex")):e(t.katex);for(var o in n)("object"==typeof exports?exports:t)[o]=n[o]}}("undefined"!=typeof self?self:this,(function(t){return function(){"use strict";var e={771:function(e){e.exports=t}},n={};function o(t){var a=n[t];if(void 0!==a)return a.exports;var r=n[t]={exports:{}};return e[t](r,r.exports,o),r.exports}o.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return o.d(e,{a:e}),e},o.d=function(t,e){for(var n in e)o.o(e,n)&&!o.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},o.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)};var a={};return function(){var t=o(771),e=o.n(t);e().__defineMacro("\\ce",(function(t){return n(t.consumeArgs(1)[0],"ce")})),e().__defineMacro("\\pu",(function(t){return n(t.consumeArgs(1)[0],"pu")})),e().__defineMacro("\\tripledash","{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}");var n=function(t,e){for(var n="",o=t.length&&t[t.length-1].loc.start,i=t.length-1;i>=0;i--)t[i].loc.start>o&&(n+=" ",o=t[i].loc.start),n+=t[i].text,o+=t[i].text.length;return r.go(a.go(n,e))},a={go:function(t,e){if(!t)return[];void 0===e&&(e="ce");var n,o="0",r={};r.parenthesisLevel=0,t=(t=(t=t.replace(/\n/g," ")).replace(/[\u2212\u2013\u2014\u2010]/g,"-")).replace(/[\u2026]/g,"...");for(var i=10,c=[];;){n!==t?(i=10,n=t):i--;var u=a.stateMachines[e],p=u.transitions[o]||u.transitions["*"];t:for(var s=0;s0))return c;if(d.revisit||(t=_.remainder),!d.toContinue)break t}}if(i<=0)throw["MhchemBugU","mhchem bug U. Please report."]}},concatArray:function(t,e){if(e)if(Array.isArray(e))for(var n=0;n":/^[=<>]/,"#":/^[#\u2261]/,"+":/^\+/,"-$":/^-(?=[\s_},;\]/]|$|\([a-z]+\))/,"-9":/^-(?=[0-9])/,"- orbital overlap":/^-(?=(?:[spd]|sp)(?:$|[\s,;\)\]\}]))/,"-":/^-/,"pm-operator":/^(?:\\pm|\$\\pm\$|\+-|\+\/-)/,operator:/^(?:\+|(?:[\-=<>]|<<|>>|\\approx|\$\\approx\$)(?=\s|$|-?[0-9]))/,arrowUpDown:/^(?:v|\(v\)|\^|\(\^\))(?=$|[\s,;\)\]\}])/,"\\bond{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\bond{","","","}")},"->":/^(?:<->|<-->|->|<-|<=>>|<<=>|<=>|[\u2192\u27F6\u21CC])/,CMT:/^[CMT](?=\[)/,"[(...)]":function(t){return a.patterns.findObserveGroups(t,"[","","","]")},"1st-level escape":/^(&|\\\\|\\hline)\s*/,"\\,":/^(?:\\[,\ ;:])/,"\\x{}{}":function(t){return a.patterns.findObserveGroups(t,"",/^\\[a-zA-Z]+\{/,"}","","","{","}","",!0)},"\\x{}":function(t){return a.patterns.findObserveGroups(t,"",/^\\[a-zA-Z]+\{/,"}","")},"\\ca":/^\\ca(?:\s+|(?![a-zA-Z]))/,"\\x":/^(?:\\[a-zA-Z]+\s*|\\[_&{}%])/,orbital:/^(?:[0-9]{1,2}[spdfgh]|[0-9]{0,2}sp)(?=$|[^a-zA-Z])/,others:/^[\/~|]/,"\\frac{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\frac{","","","}","{","","","}")},"\\overset{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\overset{","","","}","{","","","}")},"\\underset{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\underset{","","","}","{","","","}")},"\\underbrace{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\underbrace{","","","}_","{","","","}")},"\\color{(...)}0":function(t){return a.patterns.findObserveGroups(t,"\\color{","","","}")},"\\color{(...)}{(...)}1":function(t){return a.patterns.findObserveGroups(t,"\\color{","","","}","{","","","}")},"\\color(...){(...)}2":function(t){return a.patterns.findObserveGroups(t,"\\color","\\","",/^(?=\{)/,"{","","","}")},"\\ce{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\ce{","","","}")},oxidation$:/^(?:[+-][IVX]+|\\pm\s*0|\$\\pm\$\s*0)$/,"d-oxidation$":/^(?:[+-]?\s?[IVX]+|\\pm\s*0|\$\\pm\$\s*0)$/,"roman numeral":/^[IVX]+/,"1/2$":/^[+\-]?(?:[0-9]+|\$[a-z]\$|[a-z])\/[0-9]+(?:\$[a-z]\$|[a-z])?$/,amount:function(t){var e;if(e=t.match(/^(?:(?:(?:\([+\-]?[0-9]+\/[0-9]+\)|[+\-]?(?:[0-9]+|\$[a-z]\$|[a-z])\/[0-9]+|[+\-]?[0-9]+[.,][0-9]+|[+\-]?\.[0-9]+|[+\-]?[0-9]+)(?:[a-z](?=\s*[A-Z]))?)|[+\-]?[a-z](?=\s*[A-Z])|\+(?!\s))/))return{match_:e[0],remainder:t.substr(e[0].length)};var n=a.patterns.findObserveGroups(t,"","$","$","");return n&&(e=n.match_.match(/^\$(?:\(?[+\-]?(?:[0-9]*[a-z]?[+\-])?[0-9]*[a-z](?:[+\-][0-9]*[a-z]?)?\)?|\+|-)\$$/))?{match_:e[0],remainder:t.substr(e[0].length)}:null},amount2:function(t){return this.amount(t)},"(KV letters),":/^(?:[A-Z][a-z]{0,2}|i)(?=,)/,formula$:function(t){if(t.match(/^\([a-z]+\)$/))return null;var e=t.match(/^(?:[a-z]|(?:[0-9\ \+\-\,\.\(\)]+[a-z])+[0-9\ \+\-\,\.\(\)]*|(?:[a-z][0-9\ \+\-\,\.\(\)]+)+[a-z]?)$/);return e?{match_:e[0],remainder:t.substr(e[0].length)}:null},uprightEntities:/^(?:pH|pOH|pC|pK|iPr|iBu)(?=$|[^a-zA-Z])/,"/":/^\s*(\/)\s*/,"//":/^\s*(\/\/)\s*/,"*":/^\s*[*.]\s*/},findObserveGroups:function(t,e,n,o,a,r,i,c,u,p){var s=function(t,e){if("string"==typeof e)return 0!==t.indexOf(e)?null:e;var n=t.match(e);return n?n[0]:null},_=s(t,e);if(null===_)return null;if(t=t.substr(_.length),null===(_=s(t,n)))return null;var d=function(t,e,n){for(var o=0;e":{"0|1|2|3":{action_:"r=",nextState:"r"},"a|as":{action_:["output","r="],nextState:"r"},"*":{action_:["output","r="],nextState:"r"}},"+":{o:{action_:"d= kv",nextState:"d"},"d|D":{action_:"d=",nextState:"d"},q:{action_:"d=",nextState:"qd"},"qd|qD":{action_:"d=",nextState:"qd"},dq:{action_:["output","d="],nextState:"d"},3:{action_:["sb=false","output","operator"],nextState:"0"}},amount:{"0|2":{action_:"a=",nextState:"a"}},"pm-operator":{"0|1|2|a|as":{action_:["sb=false","output",{type_:"operator",option:"\\pm"}],nextState:"0"}},operator:{"0|1|2|a|as":{action_:["sb=false","output","operator"],nextState:"0"}},"-$":{"o|q":{action_:["charge or bond","output"],nextState:"qd"},d:{action_:"d=",nextState:"d"},D:{action_:["output",{type_:"bond",option:"-"}],nextState:"3"},q:{action_:"d=",nextState:"qd"},qd:{action_:"d=",nextState:"qd"},"qD|dq":{action_:["output",{type_:"bond",option:"-"}],nextState:"3"}},"-9":{"3|o":{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"3"}},"- orbital overlap":{o:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"},d:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"}},"-":{"0|1|2":{action_:[{type_:"output",option:1},"beginsWithBond=true",{type_:"bond",option:"-"}],nextState:"3"},3:{action_:{type_:"bond",option:"-"}},a:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"},as:{action_:[{type_:"output",option:2},{type_:"bond",option:"-"}],nextState:"3"},b:{action_:"b="},o:{action_:{type_:"- after o/d",option:!1},nextState:"2"},q:{action_:{type_:"- after o/d",option:!1},nextState:"2"},"d|qd|dq":{action_:{type_:"- after o/d",option:!0},nextState:"2"},"D|qD|p":{action_:["output",{type_:"bond",option:"-"}],nextState:"3"}},amount2:{"1|3":{action_:"a=",nextState:"a"}},letters:{"0|1|2|3|a|as|b|p|bp|o":{action_:"o=",nextState:"o"},"q|dq":{action_:["output","o="],nextState:"o"},"d|D|qd|qD":{action_:"o after d",nextState:"o"}},digits:{o:{action_:"q=",nextState:"q"},"d|D":{action_:"q=",nextState:"dq"},q:{action_:["output","o="],nextState:"o"},a:{action_:"o=",nextState:"o"}},"space A":{"b|p|bp":{}},space:{a:{nextState:"as"},0:{action_:"sb=false"},"1|2":{action_:"sb=true"},"r|rt|rd|rdt|rdq":{action_:"output",nextState:"0"},"*":{action_:["output","sb=true"],nextState:"1"}},"1st-level escape":{"1|2":{action_:["output",{type_:"insert+p1",option:"1st-level escape"}]},"*":{action_:["output",{type_:"insert+p1",option:"1st-level escape"}],nextState:"0"}},"[(...)]":{"r|rt":{action_:"rd=",nextState:"rd"},"rd|rdt":{action_:"rq=",nextState:"rdq"}},"...":{"o|d|D|dq|qd|qD":{action_:["output",{type_:"bond",option:"..."}],nextState:"3"},"*":{action_:[{type_:"output",option:1},{type_:"insert",option:"ellipsis"}],nextState:"1"}},". |* ":{"*":{action_:["output",{type_:"insert",option:"addition compound"}],nextState:"1"}},"state of aggregation $":{"*":{action_:["output","state of aggregation"],nextState:"1"}},"{[(":{"a|as|o":{action_:["o=","output","parenthesisLevel++"],nextState:"2"},"0|1|2|3":{action_:["o=","output","parenthesisLevel++"],nextState:"2"},"*":{action_:["output","o=","output","parenthesisLevel++"],nextState:"2"}},")]}":{"0|1|2|3|b|p|bp|o":{action_:["o=","parenthesisLevel--"],nextState:"o"},"a|as|d|D|q|qd|qD|dq":{action_:["output","o=","parenthesisLevel--"],nextState:"o"}},", ":{"*":{action_:["output","comma"],nextState:"0"}},"^_":{"*":{}},"^{(...)}|^($...$)":{"0|1|2|as":{action_:"b=",nextState:"b"},p:{action_:"b=",nextState:"bp"},"3|o":{action_:"d= kv",nextState:"D"},q:{action_:"d=",nextState:"qD"},"d|D|qd|qD|dq":{action_:["output","d="],nextState:"D"}},"^a|^\\x{}{}|^\\x{}|^\\x|'":{"0|1|2|as":{action_:"b=",nextState:"b"},p:{action_:"b=",nextState:"bp"},"3|o":{action_:"d= kv",nextState:"d"},q:{action_:"d=",nextState:"qd"},"d|qd|D|qD":{action_:"d="},dq:{action_:["output","d="],nextState:"d"}},"_{(state of aggregation)}$":{"d|D|q|qd|qD|dq":{action_:["output","q="],nextState:"q"}},"_{(...)}|_($...$)|_9|_\\x{}{}|_\\x{}|_\\x":{"0|1|2|as":{action_:"p=",nextState:"p"},b:{action_:"p=",nextState:"bp"},"3|o":{action_:"q=",nextState:"q"},"d|D":{action_:"q=",nextState:"dq"},"q|qd|qD|dq":{action_:["output","q="],nextState:"q"}},"=<>":{"0|1|2|3|a|as|o|q|d|D|qd|qD|dq":{action_:[{type_:"output",option:2},"bond"],nextState:"3"}},"#":{"0|1|2|3|a|as|o":{action_:[{type_:"output",option:2},{type_:"bond",option:"#"}],nextState:"3"}},"{}":{"*":{action_:{type_:"output",option:1},nextState:"1"}},"{...}":{"0|1|2|3|a|as|b|p|bp":{action_:"o=",nextState:"o"},"o|d|D|q|qd|qD|dq":{action_:["output","o="],nextState:"o"}},"$...$":{a:{action_:"a="},"0|1|2|3|as|b|p|bp|o":{action_:"o=",nextState:"o"},"as|o":{action_:"o="},"q|d|D|qd|qD|dq":{action_:["output","o="],nextState:"o"}},"\\bond{(...)}":{"*":{action_:[{type_:"output",option:2},"bond"],nextState:"3"}},"\\frac{(...)}":{"*":{action_:[{type_:"output",option:1},"frac-output"],nextState:"3"}},"\\overset{(...)}":{"*":{action_:[{type_:"output",option:2},"overset-output"],nextState:"3"}},"\\underset{(...)}":{"*":{action_:[{type_:"output",option:2},"underset-output"],nextState:"3"}},"\\underbrace{(...)}":{"*":{action_:[{type_:"output",option:2},"underbrace-output"],nextState:"3"}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:[{type_:"output",option:2},"color-output"],nextState:"3"}},"\\color{(...)}0":{"*":{action_:[{type_:"output",option:2},"color0-output"]}},"\\ce{(...)}":{"*":{action_:[{type_:"output",option:2},"ce"],nextState:"3"}},"\\,":{"*":{action_:[{type_:"output",option:1},"copy"],nextState:"1"}},"\\x{}{}|\\x{}|\\x":{"0|1|2|3|a|as|b|p|bp|o|c0":{action_:["o=","output"],nextState:"3"},"*":{action_:["output","o=","output"],nextState:"3"}},others:{"*":{action_:[{type_:"output",option:1},"copy"],nextState:"3"}},else2:{a:{action_:"a to o",nextState:"o",revisit:!0},as:{action_:["output","sb=true"],nextState:"1",revisit:!0},"r|rt|rd|rdt|rdq":{action_:["output"],nextState:"0",revisit:!0},"*":{action_:["output","copy"],nextState:"3"}}}),actions:{"o after d":function(t,e){var n;if((t.d||"").match(/^[0-9]+$/)){var o=t.d;t.d=void 0,n=this.output(t),t.b=o}else n=this.output(t);return a.actions["o="](t,e),n},"d= kv":function(t,e){t.d=e,t.dType="kv"},"charge or bond":function(t,e){if(t.beginsWithBond){var n=[];return a.concatArray(n,this.output(t)),a.concatArray(n,a.actions.bond(t,e,"-")),n}t.d=e},"- after o/d":function(t,e,n){var o=a.patterns.match_("orbital",t.o||""),r=a.patterns.match_("one lowercase greek letter $",t.o||""),i=a.patterns.match_("one lowercase latin letter $",t.o||""),c=a.patterns.match_("$one lowercase latin letter$ $",t.o||""),u="-"===e&&(o&&""===o.remainder||r||i||c);!u||t.a||t.b||t.p||t.d||t.q||o||!i||(t.o="$"+t.o+"$");var p=[];return u?(a.concatArray(p,this.output(t)),p.push({type_:"hyphen"})):(o=a.patterns.match_("digits",t.d||""),n&&o&&""===o.remainder?(a.concatArray(p,a.actions["d="](t,e)),a.concatArray(p,this.output(t))):(a.concatArray(p,this.output(t)),a.concatArray(p,a.actions.bond(t,e,"-")))),p},"a to o":function(t){t.o=t.a,t.a=void 0},"sb=true":function(t){t.sb=!0},"sb=false":function(t){t.sb=!1},"beginsWithBond=true":function(t){t.beginsWithBond=!0},"beginsWithBond=false":function(t){t.beginsWithBond=!1},"parenthesisLevel++":function(t){t.parenthesisLevel++},"parenthesisLevel--":function(t){t.parenthesisLevel--},"state of aggregation":function(t,e){return{type_:"state of aggregation",p1:a.go(e,"o")}},comma:function(t,e){var n=e.replace(/\s*$/,"");return n!==e&&0===t.parenthesisLevel?{type_:"comma enumeration L",p1:n}:{type_:"comma enumeration M",p1:n}},output:function(t,e,n){var o,r,i;t.r?(r="M"===t.rdt?a.go(t.rd,"tex-math"):"T"===t.rdt?[{type_:"text",p1:t.rd||""}]:a.go(t.rd),i="M"===t.rqt?a.go(t.rq,"tex-math"):"T"===t.rqt?[{type_:"text",p1:t.rq||""}]:a.go(t.rq),o={type_:"arrow",r:t.r,rd:r,rq:i}):(o=[],(t.a||t.b||t.p||t.o||t.q||t.d||n)&&(t.sb&&o.push({type_:"entitySkip"}),t.o||t.q||t.d||t.b||t.p||2===n?t.o||t.q||t.d||!t.b&&!t.p?t.o&&"kv"===t.dType&&a.patterns.match_("d-oxidation$",t.d||"")?t.dType="oxidation":t.o&&"kv"===t.dType&&!t.q&&(t.dType=void 0):(t.o=t.a,t.d=t.b,t.q=t.p,t.a=t.b=t.p=void 0):(t.o=t.a,t.a=void 0),o.push({type_:"chemfive",a:a.go(t.a,"a"),b:a.go(t.b,"bd"),p:a.go(t.p,"pq"),o:a.go(t.o,"o"),q:a.go(t.q,"pq"),d:a.go(t.d,"oxidation"===t.dType?"oxidation":"bd"),dType:t.dType})));for(var c in t)"parenthesisLevel"!==c&&"beginsWithBond"!==c&&delete t[c];return o},"oxidation-output":function(t,e){var n=["{"];return a.concatArray(n,a.go(e,"oxidation")),n.push("}"),n},"frac-output":function(t,e){return{type_:"frac-ce",p1:a.go(e[0]),p2:a.go(e[1])}},"overset-output":function(t,e){return{type_:"overset",p1:a.go(e[0]),p2:a.go(e[1])}},"underset-output":function(t,e){return{type_:"underset",p1:a.go(e[0]),p2:a.go(e[1])}},"underbrace-output":function(t,e){return{type_:"underbrace",p1:a.go(e[0]),p2:a.go(e[1])}},"color-output":function(t,e){return{type_:"color",color1:e[0],color2:a.go(e[1])}},"r=":function(t,e){t.r=e},"rdt=":function(t,e){t.rdt=e},"rd=":function(t,e){t.rd=e},"rqt=":function(t,e){t.rqt=e},"rq=":function(t,e){t.rq=e},operator:function(t,e,n){return{type_:"operator",kind_:n||e}}}},a:{transitions:a.createTransitions({empty:{"*":{}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"1",revisit:!0}},"$(...)$":{"*":{action_:"tex-math tight",nextState:"1"}},",":{"*":{action_:{type_:"insert",option:"commaDecimal"}}},else2:{"*":{action_:"copy"}}}),actions:{}},o:{transitions:a.createTransitions({empty:{"*":{}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"1",revisit:!0}},letters:{"*":{action_:"rm"}},"\\ca":{"*":{action_:{type_:"insert",option:"circa"}}},"\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"{text}"}},else2:{"*":{action_:"copy"}}}),actions:{}},text:{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"{...}":{"*":{action_:"text="}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"\\greek":{"*":{action_:["output","rm"]}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:["output","copy"]}},else:{"*":{action_:"text="}}}),actions:{output:function(t){if(t.text_){var e={type_:"text",p1:t.text_};for(var n in t)delete t[n];return e}}}},pq:{transitions:a.createTransitions({empty:{"*":{}},"state of aggregation $":{"*":{action_:"state of aggregation"}},i$:{0:{nextState:"!f",revisit:!0}},"(KV letters),":{0:{action_:"rm",nextState:"0"}},formula$:{0:{nextState:"f",revisit:!0}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"!f",revisit:!0}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"text"}},"a-z":{f:{action_:"tex-math"}},letters:{"*":{action_:"rm"}},"-9.,9":{"*":{action_:"9,9"}},",":{"*":{action_:{type_:"insert+p1",option:"comma enumeration S"}}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:"color-output"}},"\\color{(...)}0":{"*":{action_:"color0-output"}},"\\ce{(...)}":{"*":{action_:"ce"}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},else2:{"*":{action_:"copy"}}}),actions:{"state of aggregation":function(t,e){return{type_:"state of aggregation subscript",p1:a.go(e,"o")}},"color-output":function(t,e){return{type_:"color",color1:e[0],color2:a.go(e[1],"pq")}}}},bd:{transitions:a.createTransitions({empty:{"*":{}},x$:{0:{nextState:"!f",revisit:!0}},formula$:{0:{nextState:"f",revisit:!0}},else:{0:{nextState:"!f",revisit:!0}},"-9.,9 no missing 0":{"*":{action_:"9,9"}},".":{"*":{action_:{type_:"insert",option:"electron dot"}}},"a-z":{f:{action_:"tex-math"}},x:{"*":{action_:{type_:"insert",option:"KV x"}}},letters:{"*":{action_:"rm"}},"'":{"*":{action_:{type_:"insert",option:"prime"}}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"text"}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:"color-output"}},"\\color{(...)}0":{"*":{action_:"color0-output"}},"\\ce{(...)}":{"*":{action_:"ce"}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},else2:{"*":{action_:"copy"}}}),actions:{"color-output":function(t,e){return{type_:"color",color1:e[0],color2:a.go(e[1],"bd")}}}},oxidation:{transitions:a.createTransitions({empty:{"*":{}},"roman numeral":{"*":{action_:"roman-numeral"}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},else:{"*":{action_:"copy"}}}),actions:{"roman-numeral":function(t,e){return{type_:"roman numeral",p1:e||""}}}},"tex-math":{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"\\ce{(...)}":{"*":{action_:["output","ce"]}},"{...}|\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"o="}},else:{"*":{action_:"o="}}}),actions:{output:function(t){if(t.o){var e={type_:"tex-math",p1:t.o};for(var n in t)delete t[n];return e}}}},"tex-math tight":{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"\\ce{(...)}":{"*":{action_:["output","ce"]}},"{...}|\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"o="}},"-|+":{"*":{action_:"tight operator"}},else:{"*":{action_:"o="}}}),actions:{"tight operator":function(t,e){t.o=(t.o||"")+"{"+e+"}"},output:function(t){if(t.o){var e={type_:"tex-math",p1:t.o};for(var n in t)delete t[n];return e}}}},"9,9":{transitions:a.createTransitions({empty:{"*":{}},",":{"*":{action_:"comma"}},else:{"*":{action_:"copy"}}}),actions:{comma:function(){return{type_:"commaDecimal"}}}},pu:{transitions:a.createTransitions({empty:{"*":{action_:"output"}},space$:{"*":{action_:["output","space"]}},"{[(|)]}":{"0|a":{action_:"copy"}},"(-)(9)^(-9)":{0:{action_:"number^",nextState:"a"}},"(-)(9.,9)(e)(99)":{0:{action_:"enumber",nextState:"a"}},space:{"0|a":{}},"pm-operator":{"0|a":{action_:{type_:"operator",option:"\\pm"},nextState:"0"}},operator:{"0|a":{action_:"copy",nextState:"0"}},"//":{d:{action_:"o=",nextState:"/"}},"/":{d:{action_:"o=",nextState:"/"}},"{...}|else":{"0|d":{action_:"d=",nextState:"d"},a:{action_:["space","d="],nextState:"d"},"/|q":{action_:"q=",nextState:"q"}}}),actions:{enumber:function(t,e){var n=[];return"+-"===e[0]||"+/-"===e[0]?n.push("\\pm "):e[0]&&n.push(e[0]),e[1]&&(a.concatArray(n,a.go(e[1],"pu-9,9")),e[2]&&(e[2].match(/[,.]/)?a.concatArray(n,a.go(e[2],"pu-9,9")):n.push(e[2])),e[3]=e[4]||e[3],e[3]&&(e[3]=e[3].trim(),"e"===e[3]||"*"===e[3].substr(0,1)?n.push({type_:"cdot"}):n.push({type_:"times"}))),e[3]&&n.push("10^{"+e[5]+"}"),n},"number^":function(t,e){var n=[];return"+-"===e[0]||"+/-"===e[0]?n.push("\\pm "):e[0]&&n.push(e[0]),a.concatArray(n,a.go(e[1],"pu-9,9")),n.push("^{"+e[2]+"}"),n},operator:function(t,e,n){return{type_:"operator",kind_:n||e}},space:function(){return{type_:"pu-space-1"}},output:function(t){var e,n=a.patterns.match_("{(...)}",t.d||"");n&&""===n.remainder&&(t.d=n.match_);var o=a.patterns.match_("{(...)}",t.q||"");if(o&&""===o.remainder&&(t.q=o.match_),t.d&&(t.d=t.d.replace(/\u00B0C|\^oC|\^{o}C/g,"{}^{\\circ}C"),t.d=t.d.replace(/\u00B0F|\^oF|\^{o}F/g,"{}^{\\circ}F")),t.q){t.q=t.q.replace(/\u00B0C|\^oC|\^{o}C/g,"{}^{\\circ}C"),t.q=t.q.replace(/\u00B0F|\^oF|\^{o}F/g,"{}^{\\circ}F");var r={d:a.go(t.d,"pu"),q:a.go(t.q,"pu")};"//"===t.o?e={type_:"pu-frac",p1:r.d,p2:r.q}:(e=r.d,r.d.length>1||r.q.length>1?e.push({type_:" / "}):e.push({type_:"/"}),a.concatArray(e,r.q))}else e=a.go(t.d,"pu-2");for(var i in t)delete t[i];return e}}},"pu-2":{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"*":{"*":{action_:["output","cdot"],nextState:"0"}},"\\x":{"*":{action_:"rm="}},space:{"*":{action_:["output","space"],nextState:"0"}},"^{(...)}|^(-1)":{1:{action_:"^(-1)"}},"-9.,9":{0:{action_:"rm=",nextState:"0"},1:{action_:"^(-1)",nextState:"0"}},"{...}|else":{"*":{action_:"rm=",nextState:"1"}}}),actions:{cdot:function(){return{type_:"tight cdot"}},"^(-1)":function(t,e){t.rm+="^{"+e+"}"},space:function(){return{type_:"pu-space-2"}},output:function(t){var e=[];if(t.rm){var n=a.patterns.match_("{(...)}",t.rm||"");e=n&&""===n.remainder?a.go(n.match_,"pu"):{type_:"rm",p1:t.rm}}for(var o in t)delete t[o];return e}}},"pu-9,9":{transitions:a.createTransitions({empty:{0:{action_:"output-0"},o:{action_:"output-o"}},",":{0:{action_:["output-0","comma"],nextState:"o"}},".":{0:{action_:["output-0","copy"],nextState:"o"}},else:{"*":{action_:"text="}}}),actions:{comma:function(){return{type_:"commaDecimal"}},"output-0":function(t){var e=[];if(t.text_=t.text_||"",t.text_.length>4){var n=t.text_.length%3;0===n&&(n=3);for(var o=t.text_.length-3;o>0;o-=3)e.push(t.text_.substr(o,3)),e.push({type_:"1000 separator"});e.push(t.text_.substr(0,n)),e.reverse()}else e.push(t.text_);for(var a in t)delete t[a];return e},"output-o":function(t){var e=[];if(t.text_=t.text_||"",t.text_.length>4){for(var n=t.text_.length-3,o=0;o":case"\u2192":case"\u27f6":return"rightarrow";case"<-":return"leftarrow";case"<->":return"leftrightarrow";case"<--\x3e":return"rightleftarrows";case"<=>":case"\u21cc":return"rightleftharpoons";case"<=>>":return"rightequilibrium";case"<<=>":return"leftequilibrium";default:throw["MhchemBugT","mhchem bug T. Please report."]}},_getBond:function(t){switch(t){case"-":case"1":return"{-}";case"=":case"2":return"{=}";case"#":case"3":return"{\\equiv}";case"~":return"{\\tripledash}";case"~-":return"{\\mathrlap{\\raisebox{-.1em}{$-$}}\\raisebox{.1em}{$\\tripledash$}}";case"~=":case"~--":return"{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$\\tripledash$}}-}";case"-~-":return"{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$-$}}\\tripledash}";case"...":return"{{\\cdot}{\\cdot}{\\cdot}}";case"....":return"{{\\cdot}{\\cdot}{\\cdot}{\\cdot}}";case"->":return"{\\rightarrow}";case"<-":return"{\\leftarrow}";case"<":return"{<}";case">":return"{>}";default:throw["MhchemBugT","mhchem bug T. Please report."]}},_getOperator:function(t){switch(t){case"+":return" {}+{} ";case"-":return" {}-{} ";case"=":return" {}={} ";case"<":return" {}<{} ";case">":return" {}>{} ";case"<<":return" {}\\ll{} ";case">>":return" {}\\gg{} ";case"\\pm":return" {}\\pm{} ";case"\\approx":case"$\\approx$":return" {}\\approx{} ";case"v":case"(v)":return" \\downarrow{} ";case"^":case"(^)":return" \\uparrow{} ";default:throw["MhchemBugT","mhchem bug T. Please report."]}}}}(),a=a.default}()})); \ No newline at end of file diff --git a/macos/Tests/LitheTests/MarkdownPreviewRichContentTests.swift b/macos/Tests/LitheTests/MarkdownPreviewRichContentTests.swift new file mode 100644 index 000000000..f049db501 --- /dev/null +++ b/macos/Tests/LitheTests/MarkdownPreviewRichContentTests.swift @@ -0,0 +1,112 @@ +import AppKit +import CryptoKit +import Foundation +import Testing +import WebKit +@testable import Lithe + +@MainActor +struct MarkdownPreviewRichContentTests { + @Test(arguments: ["light", "dark"]) + func formulasChemistryMermaidAndFootnotesRenderOffline(appearance: String) async throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let source = try JSONDecoder().decode( + Fixture.self, + from: Data(contentsOf: root.appendingPathComponent("shared/fixtures/editor/markdown-rich-content-v1.json")) + ).source + let html = try RustCoreBridge().markdownRender(source).get().html + let directory = try #require(MarkdownPreviewResources.directoryURL) + let before = try manifest(directory) + let coordinator = MarkdownPreviewWebView.Coordinator(scrollPosition: nil) + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = coordinator + webView.uiDelegate = WebMediaCaptureDenier.shared + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 700), + styleMask: [.titled], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = webView + window.orderFront(nil) + defer { + webView.stopLoading() + webView.navigationDelegate = nil + webView.uiDelegate = nil + window.contentView = nil + window.close() + } + + coordinator.loadTemplate(in: webView) + try #require(await waitForChanges(webViewChanges(webView), timeout: .seconds(5)) { + !webView.isLoading && webView.title == "Markdown Preview" + }, "The production preview template did not load") + + let completed = TestGate() + var result: Result? + webView.callAsyncJavaScript( + """ + await window.LithePreview.update({ html, appearance }); + const content = document.getElementById('content'); + const math = content.querySelectorAll('[data-math-style]'); + return { + formulas: math.length, + renderedFormulas: content.querySelectorAll('[data-math-style] .katex').length, + formulaErrors: content.querySelectorAll('.katex-error').length, + mathml: content.querySelectorAll('math').length, + mermaid: !!content.querySelector('.mermaid-shell svg'), + diagramErrors: content.querySelectorAll('.diagram-error').length, + subscript: Array.from(content.querySelectorAll('p > sub')).some(sub => + sub.textContent === '2' && sub.previousSibling?.textContent.endsWith('H') && + sub.nextSibling?.textContent.startsWith('O')), + superscript: Array.from(content.querySelectorAll('p > sup')).some(sup => + sup.textContent === '2' && sup.previousSibling?.textContent.endsWith('x')), + strikethrough: !!content.querySelector('del'), + code: Array.from(content.querySelectorAll('code')).some(code => code.textContent === 'H~2~O x^2^'), + footnote: !!content.querySelector('a[data-footnote-ref][href="#fn-note"]') && + !!content.querySelector('#fn-note a[data-footnote-backref]'), + remoteScripts: Array.from(document.scripts).some(script => /^https?:/.test(script.src)), + remoteResources: performance.getEntriesByType('resource').some(entry => /^https?:/.test(entry.name)), + appearance: document.documentElement.dataset.theme + }; + """, + arguments: ["html": html, "appearance": appearance], in: nil, in: .page + ) { + result = $0 + completed.open() + } + try #require(await completed.waitUntilOpen(timeout: .seconds(8)), "Rich Markdown rendering timed out") + let response = try #require(result) + let values = try #require(try response.get() as? [String: Any]) + #expect(values["formulas"] as? Int == 3) + #expect(values["renderedFormulas"] as? Int == 3) + #expect(values["mathml"] as? Int == 3) + #expect(values["formulaErrors"] as? Int == 0) + #expect(values["diagramErrors"] as? Int == 0) + for key in ["mermaid", "subscript", "superscript", "strikethrough", "code", "footnote"] { + #expect(values[key] as? Bool == true, "Expected \(key): \(values)") + } + #expect(values["remoteScripts"] as? Bool == false) + #expect(values["remoteResources"] as? Bool == false) + #expect(values["appearance"] as? String == appearance) + #expect(try manifest(directory) == before, "Rendering must not mutate packaged preview resources") + } + + private func manifest(_ directory: URL) throws -> [String: String] { + let files = try #require(FileManager.default.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey])) + var result: [String: String] = [:] + for case let file as URL in files { + guard try file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue } + result[String(file.path.dropFirst(directory.path.count))] = SHA256.hash(data: try Data(contentsOf: file)) + .map { String(format: "%02x", $0) }.joined() + } + return result + } + + private struct Fixture: Decodable { + let source: String + } +} diff --git a/rust/lithe-core/src/project/markdown.rs b/rust/lithe-core/src/project/markdown.rs index 80f7c97a5..e1d451838 100644 --- a/rust/lithe-core/src/project/markdown.rs +++ b/rust/lithe-core/src/project/markdown.rs @@ -36,6 +36,7 @@ pub fn render(request: MarkdownRenderRequest) -> MarkdownRenderResponse { options.extension.autolink = true; options.extension.tasklist = true; options.extension.superscript = true; + options.extension.subscript = true; options.extension.footnotes = true; options.extension.inline_footnotes = true; options.extension.description_lists = true; @@ -170,6 +171,26 @@ mod tests { assert!(output.contains("Safe")); } + #[test] + fn preserves_rich_content_for_offline_preview() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/editor/markdown-rich-content-v1.json" + )) + .expect("rich Markdown fixture should decode"); + let output = html(fixture["source"].as_str().expect("fixture source")); + + assert!(output.contains("H2O")); + assert!(output.contains("x2")); + assert!(output.contains(">removed")); + assert!(output.contains(">H~2~O x^2^")); + assert_eq!(output.matches("data-math-style=\"inline\"").count(), 2); + assert_eq!(output.matches("data-math-style=\"display\"").count(), 1); + assert!(output.contains("\\ce{2 H2 + O2 -> 2 H2O}")); + assert!(output.contains("\\pu{123 kJ}")); + assert!(output.contains("language-mermaid")); + assert!(output.contains("class=\"footnotes\"")); + } + #[test] fn preserves_html_alignment_and_inline_badges_without_allowing_styles() { let fixture: serde_json::Value = serde_json::from_str(include_str!( diff --git a/scripts/resources/reuse-worktree-resources.mjs b/scripts/resources/reuse-worktree-resources.mjs index e60fd04d8..b75534116 100644 --- a/scripts/resources/reuse-worktree-resources.mjs +++ b/scripts/resources/reuse-worktree-resources.mjs @@ -89,8 +89,9 @@ async function readRegistry() { // not reusable build inputs, even without plugin mirror staging. // Plugin host secrets and workspace state also take this rejection route, // never a download/cache validator: their identity belongs to the installation. - // PlantUML is a pinned Git input; neither another bundle nor live render state - // is a reusable resource. Its isolated WebView never writes packaged inputs. + // Offline Markdown libraries, including PlantUML, are pinned Git inputs. + // Rebuild the target checkout's template/vendor resources before signing. + // Generated bundles and live render state are never reusable resources. const excludedResources = registry.excludedResources ?? []; if (!Array.isArray(excludedResources)) throw new Error("Invalid excluded worktree resources"); for (const resource of excludedResources) { diff --git a/scripts/resources/test-reuse-worktree-resources.mjs b/scripts/resources/test-reuse-worktree-resources.mjs index afe474469..09edb7b71 100644 --- a/scripts/resources/test-reuse-worktree-resources.mjs +++ b/scripts/resources/test-reuse-worktree-resources.mjs @@ -65,6 +65,17 @@ function reuse(extraArguments = []) { } try { + await test("offline Markdown preview assets come from Git, never another bundle", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "markdown-preview-assets"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("macos/Sources/Lithe/Resources/MarkdownPreview/")); + assert.match(resource.identity, /SHA-256/); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /markdown-preview-assets is isolated.*cannot be reused/); + }); + await test("offline PlantUML assets and live renderer state cannot cross worktrees", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "markdown-plantuml-assets"); diff --git a/scripts/resources/worktree-resources.json b/scripts/resources/worktree-resources.json index f61deaae4..192d6fe19 100644 --- a/scripts/resources/worktree-resources.json +++ b/scripts/resources/worktree-resources.json @@ -33,6 +33,16 @@ } ], "excludedResources": [ + { + "id": "markdown-preview-assets", + "reusable": false, + "locations": [ + "macos/Sources/Lithe/Resources/MarkdownPreview/", + "/Contents/Resources/Lithe_Lithe.bundle/MarkdownPreview/" + ], + "identity": "Git-tracked preview template and offline vendor scripts, styles, fonts and notices. KaTeX/mhchem versions, source archive integrity and extracted mhchem SHA-256 are fixed in THIRD_PARTY_NOTICES.md. Independent of platform architecture/toolchain; generated bundle copies have no reusable identity stamp.", + "reason": "Check out source via Git and copy this checkout's resources at build time before signing. Never copy another worktree's generated bundle or download/patch preview libraries at runtime. All rendering is in memory and the packaged files stay read-only, preserving code signatures and Sparkle delta baselines. Every reuse copy stage rejects this resource." + }, { "id": "markdown-plantuml-assets", "reusable": false, diff --git a/shared/fixtures/editor/markdown-rich-content-v1.json b/shared/fixtures/editor/markdown-rich-content-v1.json new file mode 100644 index 000000000..d1f897a4a --- /dev/null +++ b/shared/fixtures/editor/markdown-rich-content-v1.json @@ -0,0 +1,3 @@ +{ + "source": "# Rich Markdown\n\nInline math $x^2 + y^2 = z^2$ and units $\\pu{123 kJ}$.\n\n$$\n\\ce{2 H2 + O2 -> 2 H2O}\n$$\n\nWater H~2~O, power x^2^, ~~removed~~ and `H~2~O x^2^`.\n\nReference[^note].\n\n[^note]: A real footnote.\n\n```mermaid\nflowchart LR\n A[Source] --> B[Preview]\n```\n" +} diff --git a/shared/platform-feature-matrix/features/markdown-rendering.json b/shared/platform-feature-matrix/features/markdown-rendering.json index f25c1e803..47ace76cc 100644 --- a/shared/platform-feature-matrix/features/markdown-rendering.json +++ b/shared/platform-feature-matrix/features/markdown-rendering.json @@ -2,12 +2,15 @@ "id": "markdown-rendering", "area": "编辑器", "group": "Markdown", - "capability": "Markdown 预览与富文本渲染;macOS 内嵌 HTML 徽章保持行内排版和安全对齐,PlantUML 代码块可离线显示时序图与类图", + "capability": "Markdown 预览与富文本渲染;macOS 离线数学公式、mhchem 化学方程式与物理单位、Mermaid、脚注及上下标;内嵌 HTML 的徽章图片保持行内排版,并保留容器、段落与标题的安全对齐属性;PlantUML 代码块可离线显示时序图与类图", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift", "macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift", + "macos/Sources/Lithe/Resources/MarkdownPreview/index.html", + "macos/Tests/LitheTests/MarkdownPreviewRichContentTests.swift", "rust/lithe-core/src/project/markdown.rs", + "shared/fixtures/editor/markdown-rich-content-v1.json", "macos/Sources/Lithe/Resources/MarkdownPreview/preview.css", "macos/Tests/LitheTests/MarkdownPreviewHTMLTests.swift", "shared/fixtures/editor/markdown-html-v1.json", @@ -26,6 +29,6 @@ "verificationStatus": "pending" }, "owner": "Editor", - "verification": "使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。打开含上千个代码块的大型 Markdown 预览,渲染不卡住,编辑时预览不会先闪成未高亮的代码;代码里的 `$&`、`$1` 按原文显示。macOS 使用 markdown-html-v1.json 及仓库 README 验证:宽预览中同一段内多个徽章同行居中,窄分栏按可用宽度自然折行;div/p/h1 的 align 生效,显式 br 和普通段落保留,正文行内图片不拆行,相对图片与点击放大仍可用。深浅主题均检查,脚本、事件处理器、危险 URL 和任意 style 仍被剥离。 macOS PlantUML:在深浅主题打开时序图和类图 fenced block,离线生成可放大的 SVG 图片,不能在文档 DOM 执行 SVG;多个代码块顺序执行,切换文档拒绝旧结果;本地 include 被拒绝且保留源码与本地化提示,超大输入立即失败,关闭和取消释放渲染器;运行前后比较预览资源 SHA-256。", - "notes": "Windows 预览中屏幕外的段落、标题、列表和代码块使用 content-visibility: auto 跳过排版(表格除外,避免溢出被裁切);8.6 万行文档打开预览的最长主线程任务由约 10 秒降到约 1.3 秒,滚动最长任务由约 3.8 秒降到约 0.23 秒。2026-11-01 #1623:macOS 图片不再全局 display:block,Core 保留 div/p/h1-h6 的 align,但不开放 style;新增共享输入和真实 WKWebView 回归。Windows 仍使用独立的简化解析器,未在本次修改或验证其 HTML 行为,整体运行验收状态保持 pending。 2026-11-01 PlantUML:仅 macOS 接入官方 MIT @plantuml/core 1.2026.9,使用内置 Smetana;独立非持久 WebView、固定资源白名单、256KiB 源码/4MiB SVG/4096px/八秒预算,输出为被动 SVG 图片。无在线服务器、Java、Graphviz、WASM 或跨文件 include;可选主题和扩展库不打包,Windows 未修改。" + "verification": "使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。打开含上千个代码块的大型 Markdown 预览,渲染不卡住,编辑时预览不会先闪成未高亮的代码;代码里的 `$&`、`$1` 按原文显示。macOS 在深浅主题运行 markdown-rich-content-v1.json:行内数学、块级化学反应、物理单位均生成可访问 MathML,没有 katex-error;Mermaid 生成真实 SVG,脚注链接及回链存在;H~2~O 与 x^2^ 正确,双波浪线仍是删除线,代码内语法保持原文。运行前后逐文件比较预览资源哈希,确认无网络脚本和远程资源请求、bundle 不变。macOS 使用 markdown-html-v1.json 及仓库 README 验证:宽预览中同一段内多个徽章同行居中,窄分栏按可用宽度自然折行;div/p/h1 的 align 生效,显式 br 和普通段落保留,正文行内图片不拆行,相对图片与点击放大仍可用。深浅主题均检查,脚本、事件处理器、危险 URL 和任意 style 仍被剥离。 macOS PlantUML:在深浅主题打开时序图和类图 fenced block,离线生成可放大的 SVG 图片,不能在文档 DOM 执行 SVG;多个代码块顺序执行,切换文档拒绝旧结果;本地 include 被拒绝且保留源码与本地化提示,超大输入立即失败,关闭和取消释放渲染器;运行前后比较预览资源 SHA-256。", + "notes": "Windows 预览中屏幕外的段落、标题、列表和代码块使用 content-visibility: auto 跳过排版(表格除外,避免溢出被裁切);8.6 万行文档打开预览的最长主线程任务由约 10 秒降到约 1.3 秒,滚动最长任务由约 3.8 秒降到约 0.23 秒。2026-11-01 #1623:macOS 图片不再全局 display:block,Core 保留 div/p/h1-h6 的 align,但不开放 style;新增共享输入和真实 WKWebView 回归。2026-11-01 #706 增强第一批:macOS 加载与 KaTeX 0.16.0 匹配的离线 mhchem 3.3.0 扩展,Core 开启 Comrak 下标,并增加真实 WKWebView 的数学、化学、Mermaid、脚注和上下标回归。Windows 仍使用独立简化解析器,未在本批迁移,也未修改或验证其 HTML 行为;不能宣称 #706 已整体完成。完整产品和 Windows 真机验收保持 pending。 2026-11-01 PlantUML:仅 macOS 接入官方 MIT @plantuml/core 1.2026.9,使用内置 Smetana;独立非持久 WebView、固定资源白名单、256KiB 源码/4MiB SVG/4096px/八秒预算,输出为被动 SVG 图片。无在线服务器、Java、Graphviz、WASM 或跨文件 include;可选主题和扩展库不打包,Windows 未修改。" }