import AppKit import SwiftMath // MARK: - DocumentHTML // // Assembles the full, self-contained HTML document for Read mode and PDF export: // the `HTMLRenderer` body, the `HTMLTheme` stylesheet, and a second pass that // fills the renderer's placeholder elements with inlined assets (SwiftMath // glyphs and local images) as data URIs. Callout/checkbox icons are inline // Lucide SVGs emitted by `HTMLRenderer` (no asset pass needed). Inlining keeps // the document self-contained — the webview needs no file/network access. // Raw HTML in the markdown passes through per GFM, filtered by // `HTMLRenderer.filterRawHTML` (tagfilter + hardening); the page also carries a // `script-src 'none'` CSP meta as defense-in-depth (§G, ARCHITECTURE §10). @MainActor enum DocumentHTML { /// Builds a complete `…` document for `markdown`. `baseURL` is /// the document's directory, used to resolve relative image paths for inlining. static func full(markdown: String, theme: EditorTheme, callouts: [String: CalloutStyle], dark: Bool, baseURL: URL? = nil, options: ReadRenderOptions = .default) -> String { var body = HTMLRenderer.render(markdown: markdown, options: options) body = fillMath(body, theme: theme, dark: dark) body = fillImages(body, baseURL: baseURL, options: options) let css = HTMLTheme.css(theme, callouts: callouts, dark: dark, maxContentWidthPoints: options.maxContentWidthPoints) return """
\(body)
""" } // MARK: Math (SwiftMath → PNG data URI) private static let inlineMathPattern = "" private static let displayMathPattern = "
" private static func fillMath(_ html: String, theme: EditorTheme, dark: Bool) -> String { let color = NSColor(hex: dark ? "#e6e6e6" : "#1a1a1a") ?? .textColor var out = replaceMatches(html, pattern: displayMathPattern) { groups in let tex = unescapeAttr(groups[1]) guard let r = mathImage(latex: tex, display: true, fontSize: theme.fontSize, color: color), let data = pngData(r.image, scale: 2) else { return "
\(HTMLRenderer.escape(tex))
" } let uri = "data:image/png;base64,\(data.base64EncodedString())" return "
\"\(HTMLRenderer.attr(tex))\"
" } out = replaceMatches(out, pattern: inlineMathPattern) { groups in let tex = unescapeAttr(groups[1]) guard let r = mathImage(latex: tex, display: false, fontSize: theme.fontSize, color: color), let data = pngData(r.image, scale: 2) else { return "\(HTMLRenderer.escape(tex))" } let uri = "data:image/png;base64,\(data.base64EncodedString())" // Drop the image so its baseline (descent above its bottom) lands on // the text baseline — same alignment the editor computes. return "\"\(HTMLRenderer.attr(tex))\"" } return out } /// Renders LaTeX with SwiftMath to an image + baseline descent. Standalone /// (no `EditorTextView`) mirror of `EditorTextView.mathOverlay`'s core. private static func mathImage(latex: String, display: Bool, fontSize: CGFloat, color: NSColor) -> (image: NSImage, descent: CGFloat)? { let mode: MTMathUILabelMode = display ? .display : .text let math = MTMathImage(latex: latex, fontSize: fontSize, textColor: color, labelMode: mode) let insetPad: CGFloat = 2 math.contentInsets = MTEdgeInsets(top: insetPad, left: 0, bottom: insetPad, right: 0) let (error, image) = math.asImage() guard error == nil, let image else { return nil } let label = MTMathUILabel() label.latex = latex label.fontSize = fontSize label.labelMode = mode label.layout() let asc = label.displayList?.ascent ?? 0 let desc = label.displayList?.descent ?? 0 let clamped = max(asc + desc, fontSize / 2) let descent = (asc + desc - clamped) / 2 + desc + insetPad return (image, descent) } // MARK: Images (local → inlined data URI; remote → off by default) // Groups 3/4 are optional declared dimensions from an HTML `` tag // (captured with their leading space so they re-emit verbatim). private static let imagePattern = "\"([^\"]*)\"(" /// Resolves each `md-image` placeholder: local/relative paths are read and /// inlined as a data URI (self-contained, no file access needed at render /// time); a `data:` source passes through; remote `https` sources load only /// when `options.allowRemoteImages` is set. Anything that can't be shown /// gets a visible icon + reason (`ImageLoadFailure`, shared with Edit /// mode's inline preview) instead of silently showing nothing. private static func fillImages(_ html: String, baseURL: URL?, options: ReadRenderOptions) -> String { var cache: [String: String] = [:] // resolved path → data URI return replaceMatches(html, pattern: imagePattern) { groups in let src = unescapeAttr(groups[1]) let alt = groups[2] // already attribute-escaped by the renderer let dims = groups[3] + groups[4] // optional ` width="N" height="N"` if src.isEmpty { return blockedImagePlaceholder(reason:.notFound) } let lower = src.lowercased() if lower.hasPrefix("data:") { return "\"\(alt)\"\(dims)" } if lower.hasPrefix("http://") { return blockedImagePlaceholder(reason:.httpUnsupported) } if lower.hasPrefix("https://") { guard options.allowRemoteImages else { return blockedImagePlaceholder(reason:.blockedBySetting) } return "\"\(alt)\"\(dims)" } // Local: resolve against the document directory, read, inline. guard let fileURL = resolveLocalImage(src, baseURL: baseURL) else { return blockedImagePlaceholder(reason:.notFound) } if let cached = cache[fileURL.path] { return "\"\(alt)\"\(dims)" } // `resolveLocalImage`'s absolute/`~` branches don't check existence // (only the relative-path branch does), so a missing file and an // undecodable one would otherwise fail `imageDataURI` identically — // check existence first so the two get distinct, accurate messages. guard FileManager.default.fileExists(atPath: fileURL.path) else { return blockedImagePlaceholder(reason:.notFound) } guard let uri = imageDataURI(fileURL) else { return blockedImagePlaceholder(reason:.notAnImage) } cache[fileURL.path] = uri return "\"\(alt)\"\(dims)" } } /// A visible stand-in for an image that can't be shown: an icon plus a /// short reason, instead of just empty space. private static func blockedImagePlaceholder(reason: ImageLoadFailure) -> String { let icon = LucideIcons.inlineSVG("image-off") ?? "" return "\(icon)\(reason.label)" } /// Resolves a local image `path` to a file URL: absolute / `~` / `file:` /// load directly; a relative path resolves against the document's directory. private static func resolveLocalImage(_ path: String, baseURL: URL?) -> URL? { if let url = URL(string: path), url.scheme == "file" { return url } // A markdown image destination may be percent-encoded (e.g. `%20`). let decoded = path.removingPercentEncoding ?? path if decoded.hasPrefix("/") { return URL(fileURLWithPath: decoded) } if decoded.hasPrefix("~") { return URL(fileURLWithPath: (decoded as NSString).expandingTildeInPath) } guard let baseURL else { return nil } let resolved = baseURL.appendingPathComponent(decoded) return FileManager.default.fileExists(atPath: resolved.path) ? resolved : nil } /// Reads an image file and returns a `data:` URI, with the MIME type guessed /// from the file extension (covers the common web image formats). Decodes /// the bytes first (discarding the result) so a file that merely has an /// image extension but isn't actually image data is caught here — as /// "Not an image" — rather than silently inlining garbage the browser /// then fails to render with no explanation. private static func imageDataURI(_ url: URL) -> String? { guard let data = try? Data(contentsOf: url), NSImage(data: data) != nil else { return nil } let mime: String switch url.pathExtension.lowercased() { case "png": mime = "image/png" case "jpg", "jpeg": mime = "image/jpeg" case "gif": mime = "image/gif" case "svg": mime = "image/svg+xml" case "webp": mime = "image/webp" case "bmp": mime = "image/bmp" case "tiff", "tif": mime = "image/tiff" default: mime = "application/octet-stream" } return "data:\(mime);base64,\(data.base64EncodedString())" } // MARK: Bitmap / escaping helpers /// Rasterizes an `NSImage` to PNG `Data` at `scale`× its point size. private static func pngData(_ image: NSImage, scale: CGFloat) -> Data? { let size = image.size guard size.width > 0, size.height > 0, let rep = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: Int((size.width * scale).rounded()), pixelsHigh: Int((size.height * scale).rounded()), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { return nil } rep.size = size NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) image.draw(in: NSRect(origin: .zero, size: size)) NSGraphicsContext.restoreGraphicsState() return rep.representation(using: .png, properties: [:]) } /// Reverses the HTML-attribute escaping done by `HTMLRenderer.attr` so the /// raw LaTeX/symbol can be recovered from a placeholder attribute. private static func unescapeAttr(_ s: String) -> String { s.replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: """, with: "\"") .replacingOccurrences(of: "'", with: "'") .replacingOccurrences(of: "&", with: "&") // last, by convention } /// Finds every match of `pattern` and replaces it with `transform(groups)`, /// where `groups[0]` is the whole match. Replaces back-to-front so ranges /// stay valid. private static func replaceMatches(_ html: String, pattern: String, _ transform: ([String]) -> String) -> String { guard let regex = try? NSRegularExpression( pattern: pattern, options: [.dotMatchesLineSeparators]) else { return html } let ns = html as NSString let result = NSMutableString(string: html) let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length)) for m in matches.reversed() { var groups: [String] = [] for i in 0.. String { v == v.rounded() ? String(Int(v)) : String(format: "%.1f", v) } }