Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Agent 笔记:macOS Markdown 的离线 PlantUML 预览

状态:已实现

## 先说结论

macOS Markdown 的 `plantuml` 代码块使用官方 JavaScript 引擎离线渲染,不发送文档到服务器,
也不要求用户安装 Java 或 Graphviz。渲染使用独立、非持久的 WebKit 页面,完成后只把
被动 SVG 图片交给正文,不能把图中的 SVG 脚本或链接变成宿主页面能力。
基础时序图和类图在范围内;跨文件 include、可选标准库及主题没有开放。

## 问题

Issue #706 提出图表支持,原实现只保留 `language-plantuml` 类名,并没有执行渲染。
因此“代码块存在”不代表用户能看到图。官方现在提供 MIT 许可的 `@plantuml/core` 1.2026.9,
内置的 Smetana 是用来计算类图节点位置的布局引擎,可直接复用,不需要新写 UML 解析器。

## 决策

- 引擎来自固定 npm 归档,先验证 SHA-512,保存未修改的脚本、MIT 许可和上游声明,
来源 revision 和提取文件 SHA-256 放在 `THIRD_PARTY_NOTICES.md`。旧版许可不同,不得替换为旧版。
- 只在遇到 PlantUML 时创建渲染 WebView,固定协议只提供模板、引擎和入口三个文件。
不授予用户文件读取接口,网页内容安全策略(CSP,限制网页可加载哪些资源)禁止网络、
iframe 和外部图片;上游 include/标准库加载钩子显式拒绝,不能把路径转交工作区资源解析器。
- 一次只处理一个图,源码最多 256KiB,SVG 最多 4MiB,尺寸最多 4096px,
主线程拥有八秒期限。完成、失败、取消、替代请求和关闭预览都撤销期限、停止加载、断开委托,
放弃该非持久 WebView。迟到回调按请求 UUID 丢弃,正文同时按 render version 丢弃旧文档结果。
- SVG 通过 `data:image/svg+xml;base64,...` 作为 `img` 显示,不赋给 `innerHTML`。
正确做法是让浏览器按图片加载 SVG;不要因为来源是成熟上游,就把用户可影响的 SVG 当成宿主 HTML。
图片保留原有点击放大;无法渲染时保留源码,并用共享 i18n 的本地化提示说明失败。
- `WKProcessPool` 在新 WebKit 已不提供进程隔离,因此不使用它,也不声称独立 WebView 等于独立 OS 进程。
原生期限可以释放宿主的请求和 WebView,不能据此承诺终止共享 WebKit 进程中的任意死循环。

## 考虑过的备选方案

- 在线 PlantUML 服务器:会外发用户文档,断网不能用,不采用。
- Java 子进程或另带 Graphviz/WASM:增加体积、工具链和生命周期成本。官方内置 Smetana 已能处理
本次验证的时序图和类图,所以不打包 Viz.js、Graphviz、主题、emoji 或额外标准库。
- 在预览正文直接加载引擎或使用隐藏 iframe:引擎需要 DOM,普通 Worker 不适用;
iframe 与正文可能共用线程,且页面内定时器会受繁忙 JavaScript 阻塞,不把它当作有界执行保证。
- 按 PlantUML 指令名自行过滤源码:变量、预处理器和语法仍归成熟上游,安全边界放在资源白名单、
无文件加载能力、CSP、预算和被动图片输出,不再写一套语法检查器。

## 后果

常见 PlantUML 图可以离线显示,正文不新增网络能力,现有 Mermaid、数学和普通图片流程保持。
代价是每个图建立独立渲染上下文,串行处理限制内存但大量图会依次完成;复杂或被上游不支持的图仍可能失败。
跨文件 include 失败保留原文,不静默读取磁盘;无效语法也可能由上游生成诊断图片,保持上游原始诊断。

模板、引擎和许可是 Git 输入,构建时复制后签名。运行时只有源码、回调和图片的内存状态,
不下载、不生成缓存、不写 bundle,不改变代码签名或 Sparkle 增量基线。
资源复用清单显式拒绝跨工作树复制这些生成 bundle 或运行态;Windows 产品不在本次范围内。

## 验证

- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter MarkdownPlantUMLTests`:
真实 WebKit 在深浅主题渲染时序图、类图,逐文件比较运行前后的打包资源 SHA-256;
验证取消、超大输入拒绝、正文被动图片、include 拒绝时源码/本地化回退、旧结果隔离。
- `node --test scripts/macos/test/test-markdown-preview.mjs`:既有目录与刷新状态回归。
- `node --test scripts/resources/test-reuse-worktree-resources.mjs`:资源隔离与固定身份。
- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`。
- `node scripts/i18n/generate-i18n.mjs --check`。

组件运行验证不代替完整应用视觉验收;不以本地新编译器通过冒充 CI 固定工具链通过。

## 适用范围

- `macos/Sources/Lithe/Platform/MacOS/MarkdownPlantUMLRenderer.swift`
- `macos/Sources/Lithe/Platform/MacOS/MarkdownPreviewWebView.swift`
- `macos/Sources/Lithe/Resources/MarkdownPreview/`
- `macos/Tests/LitheTests/MarkdownPlantUMLTests.swift`
- `scripts/resources/worktree-resources.json`
9 changes: 9 additions & 0 deletions docs/ci-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,15 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono

以下目录不应直接复制或跨工作树共享:

- macOS Markdown PlantUML:Git 中的 `MarkdownPreview/vendor/plantuml/`、
`plantuml.html` 与 `plantuml-renderer.js` 随当前 checkout 打包,来源为固定的
MIT `@plantuml/core` 1.2026.9,归档 SHA-512、引擎 SHA-256 和上游 revision 见
`THIRD_PARTY_NOTICES.md`。使用内置 Smetana,不另下载 Java、Graphviz 或 WASM。
输入与平台架构、工具链无关;生成 bundle 没有跨 worktree identity stamp。
运行时一项请求拥有一个非持久 WebView,源码、回调和 SVG 图片只在内存存活,
完成、取消、八秒超时或关闭预览后释放;不下载、不写发行目录、不改签名及 Sparkle 基线。
`excludedResources.markdown-plantuml-assets` 走资源脚本通用拒绝路由,所有复制阶段都禁止
复用其他工作树的 bundle 或运行态。
- AutoBuild 应用发布校验 staging:
`<RUNNER_TEMP>/lithe-autobuild-<invocation>/{baselines,verification}/`。
源码 SHA、应用 release/tag/asset、当前 build/ISO 时间与本次发布共同决定输入;
Expand Down
2 changes: 2 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -2764,6 +2764,7 @@
"Plan before making changes" = "Plan before making changes";
"Planned Git command — waiting to start" = "Planned Git command — waiting to start";
"Planned command" = "Planned command";
"PlantUML could not render this diagram. Showing its source instead." = "PlantUML could not render this diagram. Showing its source instead.";
"Platforms" = "Platforms";
"Plugin" = "Plugin";
"Plugin %@ %@ is a retired native package and is no longer loaded. Reinstall it from Plugin Management to update." = "Plugin %@ %@ is a retired native package and is no longer loaded. Reinstall it from Plugin Management to update.";
Expand Down Expand Up @@ -4839,6 +4840,7 @@
"macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS could not prepare the update disk image. Open the Release page and install it manually.";
"main Java" = "main Java";
"main resources" = "main resources";
"markdown.plantumlRenderFailed" = "PlantUML could not render this diagram. Showing its source instead.";
"matches" = "matches";
"maven.title" = "Maven";
"menu.copy" = "Copy";
Expand Down
2 changes: 2 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -2764,6 +2764,7 @@
"Plan before making changes" = "先制定计划,再进行修改。";
"Planned Git command — waiting to start" = "Git 命令计划 — 等待启动";
"Planned command" = "计划执行的命令";
"PlantUML could not render this diagram. Showing its source instead." = "PlantUML 无法渲染此图表,已显示源码。";
"Platforms" = "支持平台";
"Plugin" = "插件";
"Plugin %@ %@ is a retired native package and is no longer loaded. Reinstall it from Plugin Management to update." = "插件 %@ %@ 是已淘汰的原生插件包,不再加载。请在插件管理中重新安装以更新。";
Expand Down Expand Up @@ -4839,6 +4840,7 @@
"macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS 无法准备更新磁盘映像。请打开 Release 页面手动安装。";
"main Java" = "主代码";
"main resources" = "主资源";
"markdown.plantumlRenderFailed" = "PlantUML 无法渲染此图表,已显示源码。";
"matches" = "个匹配项";
"maven.title" = "Maven";
"menu.copy" = "复制";
Expand Down
149 changes: 149 additions & 0 deletions macos/Sources/Lithe/Platform/MacOS/MarkdownPlantUMLRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import Foundation
import WebKit

/// Runs the offline upstream engine outside the document preview's WebView.
/// Note: .agents/notes/implemented/feature/2026-11-01-macos-offline-plantuml.md
@MainActor
final class MarkdownPlantUMLRenderer: NSObject, WKNavigationDelegate {
static let messageName = "markdownPlantUML"
static let maximumSourceBytes = 256 * 1024
static let maximumSVGBytes = 4 * 1024 * 1024
static let timeout: TimeInterval = 8

private struct Request {
let id: UUID
let source: String
let dark: Bool
let webView: WKWebView
let deadline: DispatchWorkItem
let reply: (Any?, String?) -> Void
}

private var request: Request?

func render(source: String, dark: Bool, reply: @escaping (Any?, String?) -> Void) {
cancel()
guard source.utf8.count <= Self.maximumSourceBytes else {
reply(nil, "diagramSourceTooLarge")
return
}
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
// The nonpersistent renderer has its own JavaScript context and no
// document bridge. Its native deadline owns disposal.
configuration.setURLSchemeHandler(MarkdownPlantUMLResources(), forURLScheme: MarkdownPlantUMLResources.scheme)
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 800, height: 600), configuration: configuration)
webView.navigationDelegate = self
webView.uiDelegate = WebMediaCaptureDenier.shared
let id = UUID()
let deadline = DispatchWorkItem { [weak self] in self?.finish(id, error: "diagramTimedOut") }
request = Request(id: id, source: source, dark: dark, webView: webView, deadline: deadline, reply: reply)
DispatchQueue.main.asyncAfter(deadline: .now() + Self.timeout, execute: deadline)
webView.load(URLRequest(url: MarkdownPlantUMLResources.pageURL))
}

func cancel() {
guard let request else { return }
finish(request.id, error: "diagramCancelled")
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard let request, request.webView === webView else { return }
let id = request.id
webView.callAsyncJavaScript(
"return window.LithePlantUML.render(source, dark)",
arguments: ["source": request.source, "dark": request.dark], in: nil, in: .page
) { [weak self] result in
guard let self, self.request?.id == id else { return }
guard case let .success(value) = result,
let svg = value as? String,
svg.utf8.count <= Self.maximumSVGBytes,
svg.contains("<svg") else {
self.finish(id, error: "diagramRenderFailed")
return
}
// SVG is returned only as a passive image, never inserted into the
// document DOM. SVG images cannot execute scripts or navigate.
self.finish(id, image: "data:image/svg+xml;base64," + Data(svg.utf8).base64EncodedString())
}
}

func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
failed(webView)
}

func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
failed(webView)
}

func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
failed(webView)
}

func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
decisionHandler(navigationAction.request.url == MarkdownPlantUMLResources.pageURL ? .allow : .cancel)
}

private func failed(_ webView: WKWebView) {
guard let request, request.webView === webView else { return }
finish(request.id, error: "diagramRenderFailed")
}

private func finish(_ id: UUID, image: String? = nil, error: String? = nil) {
guard let current = request, current.id == id else { return }
request = nil
current.deadline.cancel()
current.webView.stopLoading()
current.webView.navigationDelegate = nil
current.webView.uiDelegate = nil
current.reply(image, error)
}
}

/// Exposes only fixed, bundled renderer inputs; no user paths or URL queries.
private final class MarkdownPlantUMLResources: NSObject, WKURLSchemeHandler, @unchecked Sendable {
static let scheme = "lithe-plantuml"
static let pageURL = URL(string: "lithe-plantuml://renderer/plantuml.html")!
private static let files = [
"/plantuml.html": "text/html",
"/plantuml-renderer.js": "text/javascript",
"/vendor/plantuml/plantuml.js": "text/javascript"
]
private let tasks = LocalFileSchemeTasks()
private let queue = DispatchQueue(label: "lithe.markdown.plantuml.resources", qos: .userInitiated)

func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) {
let id = tasks.register(task)
let url = task.request.url
let directory = MarkdownPreviewResources.directoryURL
queue.async { [self] in
do {
guard let url, url.scheme == Self.scheme, url.host == "renderer",
url.query == nil, url.fragment == nil,
let mime = Self.files[url.path], let directory else {
throw URLError(.noPermissionsToReadFile)
}
let data = try Data(contentsOf: directory.appendingPathComponent(String(url.path.dropFirst())))
let response = URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: "utf-8")
DispatchQueue.main.async { [self] in
guard tasks.claim(id) else { return }
task.didReceive(response)
task.didReceive(data)
task.didFinish()
}
} catch {
DispatchQueue.main.async { [self] in
guard tasks.claim(id) else { return }
task.didFailWithError(error)
}
}
}
}

func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {
tasks.stop(task)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ struct MarkdownPreviewWebView: NSViewRepresentable {
"html": html,
"documentURL": documentURL.absoluteString,
"workspaceURL": workspaceURL?.absoluteString ?? "",
"appearance": appearance
"appearance": appearance,
"plantumlFailure": L10n.string("PlantUML could not render this diagram. Showing its source instead.")
]
}
}
Expand All @@ -199,6 +200,7 @@ struct MarkdownPreviewWebView: NSViewRepresentable {
let pagePreferences = WKWebpagePreferences()
pagePreferences.allowsContentJavaScript = true
configuration.defaultWebpagePreferences = pagePreferences
context.coordinator.installPlantUMLBridge(in: configuration)

let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
Expand All @@ -220,10 +222,20 @@ struct MarkdownPreviewWebView: NSViewRepresentable {
context.coordinator.updateScrollPosition(in: webView)
}

static func dismantleNSView(_ webView: WKWebView, coordinator: Coordinator) {
coordinator.plantuml.cancel()
webView.stopLoading()
webView.configuration.userContentController.removeScriptMessageHandler(forName: Coordinator.scrollMessageName)
webView.configuration.userContentController.removeScriptMessageHandler(forName: MarkdownPlantUMLRenderer.messageName)
webView.navigationDelegate = nil
webView.uiDelegate = nil
}

final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
static let scrollMessageName = "markdownScrollSync"

let assetHandler = MarkdownPreviewAssetSchemeHandler()
let plantuml = MarkdownPlantUMLRenderer()
var scrollPosition: Binding<MarkdownScrollPosition>?
private var isReady = false
private var pendingPayload: Payload?
Expand All @@ -235,6 +247,12 @@ struct MarkdownPreviewWebView: NSViewRepresentable {
self.scrollPosition = scrollPosition
}

func installPlantUMLBridge(in configuration: WKWebViewConfiguration) {
configuration.userContentController.addScriptMessageHandler(
MarkdownPlantUMLMessages(self), contentWorld: .page, name: MarkdownPlantUMLRenderer.messageName
)
}

func loadTemplate(in webView: WKWebView) {
guard let templateURL = MarkdownPreviewResources.templateURL,
let directoryURL = MarkdownPreviewResources.directoryURL else {
Expand Down Expand Up @@ -349,3 +367,34 @@ struct MarkdownPreviewWebView: NSViewRepresentable {
}
}
}

/// WebKit retains its message handlers; do not let that retain the renderer.
private final class MarkdownPlantUMLMessages: NSObject, WKScriptMessageHandlerWithReply {
weak var coordinator: MarkdownPreviewWebView.Coordinator?

init(_ coordinator: MarkdownPreviewWebView.Coordinator) {
self.coordinator = coordinator
}

func userContentController(
_ controller: WKUserContentController, didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void
) {
guard let coordinator, message.frameInfo.isMainFrame,
message.frameInfo.request.url == MarkdownPreviewResources.templateURL,
let body = message.body as? [String: Any] else {
replyHandler(nil, "diagramRequestRejected")
return
}
if body["cancel"] as? Bool == true {
coordinator.plantuml.cancel()
replyHandler(true, nil)
return
}
guard let source = body["source"] as? String, let dark = body["dark"] as? Bool else {
replyHandler(nil, "diagramRequestRejected")
return
}
coordinator.plantuml.render(source: source, dark: dark, reply: replyHandler)
}
}
Loading
Loading