How does loora's tech stack work with Rust, GUI, SVG and HTML?

HEAD
8e8ba3dfix(security): secure desktop bridge and assistant upserts
Avatar of xeno-sun
Xeno Sun
posted

全面解析这个 repo 的技术栈和核心问题的解决方案; 另外我看到作者在用 rust + gui, 但是产品描述中解释主要使用 svg + html dom 实现, 详细解释

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

完美!现在我有了全面的理解。让我为用户写一个详细的解析文档:


#🎨 Loora - 全面技术栈与核心架构解析

##📋 Executive Summary

Loora 是一个创新的无限画布设计工具,核心特性是允许 Agent(AI 代理)通过 MCP(Model Context Protocol)实时协作编辑设计文档。项目采用**Rust(后端服务)+ TypeScript/React(前端)**的混合架构,其中最有意思的是:虽然用 Rust 构建 GUI 无关的服务,但整个 UI 渲染完全基于现代 Web 技术(SVG + HTML DOM + CSS)


##🏗️ 技术栈概览

###核心技术选型

层级技术栈用途
前端框架React 19 + TypeScriptUI 组件、canvas 渲染
构建工具Vite + TanStack Start打包、SSR、路由
样式系统Tailwind CSS 4.3设计系统、主题
桌面应用Tauri跨平台原生窗口
包管理Bun(Monorepo)工作空间、依赖管理
后端服务Rust(Axum)MCP 服务、WebSocket 服务
数据库Neon(Postgres)+ Drizzle ORM数据持久化
认证Better AuthOAuth、GitHub 集成
实时通信WebSocket(ws-server)协作、实时同步
RPC 框架oRPC类型安全的 Client-Server 通信
计费Polar订阅管理

###Monorepo 工作空间结构

loora/
├── apps/
│   ├── web          # TanStack Start web 应用(主应用)
│   └── desktop      # Tauri 桌面应用(共用同一套 UI)
├── crates/          # Rust 服务
│   ├── mcp-server   # MCP 协议实现(Agent 通信)
│   └── ws-server    # WebSocket 实时服务
├── packages/        # 共享 TypeScript 包
│   ├── canvas       # ⭐ 核心:document model、engine、export/import
│   ├── editor       # 编辑器 UI(panels、menus、client sync)
│   ├── agent        # Agent 工具定义(MCP 工具)
│   ├── rpc          # oRPC 路由、storage、history
│   ├── shell        # 认证后的产品界面
│   ├── ui           # 设计系统(Button、Dialog 等)
│   ├── db           # Drizzle schema + migrations
│   ├── auth         # Better Auth 集成
│   ├── realtime     # 实时协议
│   ├── assistant    # 内置 ChatGPT Agent
│   └── billing      # Polar 订阅
└── skills/          # AI 技能包(设计指南)

##🎯 核心问题解决方案:为什么 Rust + SVG/DOM?

###问题背景

你发现了一个有趣的矛盾:

  1. 项目用 Rust 编写了 GUI 服务crates/mcp-servercrates/ws-server
  2. 但产品描述说用 SVG + HTML DOM 实现

这不是矛盾,而是精妙的分层设计。让我详细解释:

###架构分层解释

####第一层:Rust 服务(后端基础设施)

Rust Crates:
├── mcp-server (crates/mcp-server)
│   ├── 用途:MCP 协议实现
│   ├── 依赖:axum、rmcp、redis、tokio
│   ├── 功能:
│   │   ├── 接收来自 Claude/Cursor/Agent 的 MCP 请求
│   │   ├── 验证 OAuth token + rate limiting
│   │   ├── 调用 canvas 工具集(通过 HTTP 回调到 web API)
│   │   └── 响应结构化数据(JSON)
│   └── 地址:mcp.loora.design(独立部署)
│
└── ws-server (crates/ws-server)
    ├── 用途:实时 WebSocket 通信
    ├── 依赖:axum、tokio、redis
    ├── 功能:
    │   ├── 管理多人编辑的 WebSocket 连接
    │   ├── Redis pub/sub 分布式消息总线
    │   ├── 广播 canvas 事务更新
    │   └── 处理 presence(谁在编辑)
    └── 地址:ws.loora.design(独立部署)

为什么用 Rust?

  • 高性能:处理高并发 WebSocket 连接
  • 内存安全:关键的网络层不会有内存泄漏
  • 轻量:无 GC 开销,Docker 容器足够小
  • 并发模型:Tokio async/await 天生适合 I/O 密集型服务

####第二层:Canvas 模型(TypeScript,不涉及 GUI)

// packages/canvas/src/model.ts
export type CanvasNode = 
  | PageNode      // 文档根节点
  | FrameNode     // 容器
  | TextNode      // 文本
  | ImageNode     // 图片
  | VectorNode    // 矢量(SVG path)
  | ShapeNode     // 形状(圆、矩形)
  | ComponentNode // 可复用组件
  | InstanceNode  // 组件实例

export interface CanvasDocument {
  id: string
  version: number
  schemaVersion: 2
  nodes: Record<NodeId, CanvasNode>
  breakpoints: CanvasBreakpoint[]
  tokens: DesignToken[]
  themes: CanvasTheme[]
  activeTheme: ThemeId
  animations: CanvasAnimation[]
}

// packages/canvas/src/engine.ts
export type CanvasOperation =
  | { type: 'node.insert'; node: CanvasNode }
  | { type: 'node.patch'; id: NodeId; patch: NodeMutationPatch }
  | { type: 'node.move'; id: NodeId; parentId: NodeId | null; order: number }
  | { type: 'node.delete'; id: NodeId }
  // ... 其他操作

export interface CanvasTransaction {
  id: string
  label: string
  operations: CanvasOperation[]
  preconditions?: CanvasFieldPrecondition[]
  documentUpdatedAt?: number
}

这一层完全是数据模型和业务逻辑,不涉及 GUI

####第三层:React 渲染层(SVG + HTML DOM)

// packages/canvas/src/react.tsx
export function CanvasProvider({ engine, children }: CanvasProviderProps) {
  // 提供 canvas context 给整个编辑器
}

// 核心渲染函数
function RenderNode({ id, width, topLevel }: RenderNodeProps) {
  const node = useCanvasNode(id)
  const style = computeNodeStyle(node)  // 转换成 CSS
  
  if (node.type === 'vector') {
    // 矢量图:渲染为 <svg><path /></svg>
    return (
      <svg viewBox={node.viewBox} style={style}>
        {node.paths.map(path => (
          <path key={path.id} d={path.d} fill={path.fill} />
        ))}
      </svg>
    )
  }
  
  if (node.type === 'text') {
    // 文本:渲染为 <div> + CSS
    return <div style={style}>{node.content}</div>
  }
  
  if (node.type === 'shape') {
    // 形状:用 CSS border-radius 等实现
    return <div style={style} />
  }
  
  // frame、component 等:渲染为结构化 div
  return <div style={style}>{/* children */}</div>
}

为什么用 SVG + HTML DOM?

  1. 矢量图用 SVG:编辑 path d 属性、viewBox 属性
  2. 布局用 HTML + CSS
    • Flexbox(display: flex
    • CSS Grid(display: grid
    • Absolute positioning(position: absolute
  3. 样式用 CSS Propertiesfillstrokeborder-radiusopacity
  4. 优势
    • 浏览器原生支持,不需要 Canvas API 或 WebGL
    • 可以导出为真实 HTML/CSS/JSX
    • 支持 CSS 动画和过渡
    • 可访问性(a11y)友好

####第四层:导出层(HTML/JSX 生成)

// packages/canvas/src/export.ts
export async function exportCanvasAsHtml(
  document: CanvasDocument,
  options: CanvasExportOptions
): Promise<string> {
  // 遍历 canvas tree,生成实际的 HTML
  // <div style="display:flex; ...">
  //   <svg viewBox="..." aria-hidden="true">
  //     <path d="..." />
  //   </svg>
  //   <div style="font-size: 16px;">Text</div>
  // </div>
  
  // 同时生成对应的 CSS
  // 用 Tailwind preflight 初始化基线
  // 用 design tokens 生成 CSS variables
}

export async function exportCanvasAsReact(
  document: CanvasDocument,
  options: CanvasExportOptions
): Promise<{ jsx: string; css: string }> {
  // 生成 React 组件代码
  // 遵循原始的层级结构和 semantic tags
}

export async function renderElementToPng(
  element: HTMLElement | SVGElement,
  options: CanvasPngRenderOptions
): Promise<Buffer> {
  // 用浏览器 API 将 DOM 元素转换为 PNG
  // 使用 XMLSerializer + <foreignObject> 技巧
}

##🔄 数据流:从 Agent 到 Canvas 再到 HTML

###完整编辑流程

┌─────────────────────┐
│   Claude (Agent)    │ 
│ 通过 MCP 调用工具   │
└──────────┬──────────┘
           │
           │ HTTP MCP 请求
           │ (getDesignContext, readTree, patchNode, etc.)
           ▼
┌──────────────────────────────────────┐
│  Rust MCP Server (mcp.loora.design)  │
│  crates/mcp-server                   │
│                                      │
│  ├─ auth.rs      OAuth 验证          │
│  ├─ rate_limit.rs 速率限制           │
│  └─ server.rs    处理 MCP 请求      │
│     └─> 调用 Web API (oRPC)          │
└──────────┬──────────────────────────┘
           │
           │ oRPC 请求 (JSON-RPC)
           │
           ▼
┌──────────────────────────────────────┐
│   Web App (apps/web)                 │
│   Route: /api/rpc/$                  │
│                                      │
│   packages/rpc/src/mcp-procedures.ts │
│   ├─ getDesignContext()   获取设计   │
│   ├─ readTree()           读取 tree  │
│   └─ applyTransaction()   应用编辑   │
└──────────┬──────────────────────────┘
           │
           │ 读/写 Postgres (Drizzle)
           │
           ▼
┌──────────────────────────────────────┐
│   Canvas Engine (packages/canvas)    │
│                                      │
│   ├─ model.ts    定义数据结构       │
│   ├─ engine.ts   实现事务处理       │
│   └─ merge.ts    冲突合并 (CRDT)    │
│                                      │
│   CanvasOperation[] → CanvasDocument│
└──────────┬──────────────────────────┘
           │
           │ WebSocket 广播 (ws-server)
           │
           ▼
┌──────────────────────────────────────┐
│   React 编辑器 (packages/editor)     │
│   react.tsx                          │
│   ├─ CanvasProvider                 │
│   ├─ CanvasNodeRenderer              │
│   └─ 实时更新 UI                     │
│                                      │
│   Canvas DOM 树 + Viewport overlay   │
└──────────┬──────────────────────────┘
           │
           │ 用户可导出为:
           ▼
    ┌──────────────────┐
    │  • HTML + CSS    │
    │  • React TSX     │
    │  • JSON          │
    │  • PNG          │
    └──────────────────┘

###关键数据结构转换

####1️⃣ Model → Transactions (Agent 编辑时)

// Agent 通过 MCP 工具调用
// Input: { designId, nodeId: "frame-1", layout: { x: 100, y: 50 } }

// Canvas Server 处理
const transaction: CanvasTransaction = {
  id: 'txn-123',
  label: 'Agent moved frame',
  operations: [
    {
      type: 'node.patch',
      id: 'frame-1',
      patch: {
        layout: {
          x: 100,
          y: 50,
          // 其他保持不变
        }
      }
    }
  ]
}

// Engine 应用
const result = engine.apply(transaction)
// → 返回新的 CanvasDocument
// → 广播到所有连接的编辑器

####2️⃣ Document → React DOM (实时渲染)

// Canvas 树结构
{
  nodes: {
    'page-1': {
      type: 'page',
      id: 'page-1',
      children: ['frame-1']
    },
    'frame-1': {
      type: 'frame',
      id: 'frame-1',
      layout: { x: 100, y: 50, width: 300, height: 200 },
      style: { fills: [{ type: 'solid', color: '#FF0000' }] },
      children: ['text-1']
    },
    'text-1': {
      type: 'text',
      id: 'text-1',
      content: 'Hello'
    }
  }
}

// React 渲染为
<CanvasProvider engine={engine}>
  <div 
    data-loora-node="frame-1"
    style={{
      position: 'absolute',
      left: '100px',
      top: '50px',
      width: '300px',
      height: '200px',
      background: '#FF0000'
    }}
  >
    <div style={{ fontSize: '16px' }}>Hello</div>
  </div>
</CanvasProvider>

####3️⃣ DOM → HTML Export (导出时)

// 遍历 canvas tree,生成字符串

export function renderNode(
  node: CanvasNode,
  index: CanvasChildIndex
): string {
  const css = serializeStyle(node.style, node.layout)
  
  if (node.type === 'frame') {
    const children = orderedChildren(document, node.id, index)
      .map(child => renderNode(child, index))
      .join('')
    return `
      <div style="${css}">
        ${children}
      </div>
    `
  }
  
  if (node.type === 'vector') {
    const paths = node.paths
      .map(p => `<path d="${p.d}" fill="${p.fill}" />`)
      .join('')
    return `<svg viewBox="${node.viewBox}">${paths}</svg>`
  }
  
  // ...
}

// 输出真实 HTML 字符串
// <div style="display:flex;flex-direction:row;...">
//   <svg viewBox="0 0 100 100" aria-hidden="true">
//     <path d="M 10 10 L 90 90" fill="none" stroke="#000" />
//   </svg>
// </div>

##🛠️ 关键技术深度解析

###1. Canvas Engine:事务引擎

// packages/canvas/src/engine.ts

export class CanvasEngine {
  document: CanvasDocument
  
  apply(transaction: CanvasTransaction): CanvasApplyResult {
    // 1. 验证前置条件
    if (transaction.preconditions) {
      for (const cond of transaction.preconditions) {
        const field = this.getField(cond)
        if (field.hash !== cond.hash) {
          throw new Error(`Precondition failed: ${cond.path}`)
        }
      }
    }
    
    // 2. 顺序执行每个 operation
    let doc = this.document
    for (const op of transaction.operations) {
      doc = this.applyOperation(doc, op)
    }
    
    // 3. 触发事件、更新订阅者
    this.document = doc
    this.revision++
    this.listeners.forEach(fn => fn())
    
    return { newDocument: doc, revision: this.revision }
  }
  
  applyOperation(doc: CanvasDocument, op: CanvasOperation) {
    switch (op.type) {
      case 'node.insert':
        return { ...doc, nodes: { ...doc.nodes, [op.node.id]: op.node } }
      
      case 'node.patch':
        return {
          ...doc,
          nodes: {
            ...doc.nodes,
            [op.id]: { ...doc.nodes[op.id], ...op.patch }
          }
        }
      
      case 'node.move':
        // 更新 parent、sibling 的 order
        return { /* ... */ }
      
      // ...
    }
  }
}

为什么用事务?

  • 原子性:多个操作要么全部成功,要么全部失败
  • 合并:CRDT(冲突自由复制数据类型)支持离线编辑
  • 版本历史:每个事务都是一个 checkpoint
  • Undo/Redo:逆向执行事务

###2. Merge 策略:CRDT

// packages/canvas/src/merge.ts

export function mergeDocuments(
  base: CanvasDocument,
  ours: CanvasTransaction[],
  theirs: CanvasTransaction[]
): { document: CanvasDocument; conflicts: string[] } {
  
  // 执行 ours
  let merged = base
  for (const txn of ours) merged = apply(merged, txn)
  
  // 尝试应用 theirs(如果冲突,使用特定的合并规则)
  for (const txn of theirs) {
    try {
      merged = apply(merged, txn)
    } catch (err) {
      // CRDT 的关键:用 order 和 timestamp 解决冲突
      // 例如,两个人同时在同一个父节点插入子节点
      // 用 order 值自动排序,无需手动冲突解决
      merged = applyCrdtMerge(merged, txn, base)
    }
  }
  
  return { document: merged }
}

###3. 响应式布局计算

// packages/canvas/src/style-css.ts

export function layoutDeclarations(
  layout: CanvasLayout,
  context: { parent?: LayoutParent; asRoot: boolean }
): string[] {
  const decls: string[] = []
  
  // Flex 容器
  if (layout.mode === 'flex') {
    decls.push('display: flex')
    decls.push(`flex-direction: ${layout.direction || 'row'}`)
    if (layout.gap) decls.push(`gap: ${layout.gap}px`)
    decls.push(`padding: ${serializePadding(layout.padding)}`)
  }
  
  // Grid 容器
  if (layout.mode === 'grid') {
    decls.push('display: grid')
    if (layout.columns) decls.push(`grid-template-columns: repeat(${layout.columns}, 1fr)`)
  }
  
  // 尺寸
  if (layout.width.unit === 'fill' && context.parent?.mode === 'flex') {
    // flex 中的 fill = flex: 1
    decls.push('flex: 1')
  } else {
    decls.push(`width: ${lengthValue(layout.width, 'width')}`)
  }
  
  // ... 更多
  
  return decls
}

支持的布局模式:

  • Absoluteposition: absolute,用 x/y 定位
  • Flexdisplay: flex,direction 控制行/列,gap、padding、align、justify
  • Griddisplay: grid,columns 定义列数

###4. 实时协作:WebSocket + Redis

// crates/ws-server/src/main.rs

// Redis pub/sub
let client = redis::Client::open(redis_url)?
  .get_connection_manager()?

// 处理 WebSocket 连接
tokio::select! {
  msg = websocket.recv() => {
    match msg {
      Some(WsMessage::Text(payload)) => {
        let txn: CanvasTransaction = serde_json::from_str(&payload)?
        
        // 广播给同设计文件的所有客户端
        client.publish(
          format!("design:{}", design_id),
          serde_json::to_string(&txn)?
        ).await?
      }
    }
  }
  
  msg = pubsub.on_message() => {
    // 接收其他客户端的更新
    websocket.send(WsMessage::Text(msg.get_payload()?)).await?
  }
}

###5. MCP 工具体系

// packages/agent/src/canvas-tools.ts

// 定义 Zod schema 用于 MCP 验证
const createFrameSchema = z.object({
  designId: z.string(),
  name: z.string().max(256),
  width: lengthSchema,
  height: lengthSchema,
  x: z.number().finite(),
  y: z.number().finite(),
})

// crates/mcp-server/src/tools.json
// 内置所有可用工具的描述
[
  {
    "name": "createFrame",
    "description": "Create a new frame on the canvas",
    "inputSchema": {
      "type": "object",
      "properties": { /* ... */ }
    }
  },
  {
    "name": "patchNode",
    "description": "Update a node's properties (layout, style, etc.)"
  },
  {
    "name": "listDesigns",
    "description": "List all designs accessible to the user"
  }
]

##📊 多人协作流程详解

###场景:Claude 和用户同时编辑

时刻 0:
┌─────────────────────────────────────┐
│ Canvas (Main)                        │
│ frame-1: { x: 0, y: 0, width: 100 }│
└─────────────────────────────────────┘

时刻 1ms: 用户在编辑器中拖动 frame-1 → x: 50

编辑器生成事务:
Transaction {
  id: 'txn-user-1',
  operations: [
    {
      type: 'node.patch',
      id: 'frame-1',
      patch: { layout: { x: 50 } }
    }
  ]
}

↓ WebSocket 发送给 ws-server
↓ ws-server 广播给所有客户端
↓ Redis pub/sub 持久化

时刻 5ms: Claude 同时拖动 frame-1 → x: 100

MCP 工具调用:
patchNode({
  designId,
  nodeId: 'frame-1',
  layout: { x: 100 }
})

↓ MCP Server 调用 Web API
↓ 创建事务 Transaction { id: 'txn-claude-1', operations: [...] }
↓ Canvas Engine 应用

** 此时冲突了:frame-1 的 x 有两个值 **

Merge 策略(CRDT):
- 用 transaction.id 作为唯一标识
- 用 timestamp 或 lamport clock 排序
- 后来的编辑胜出(Last-Write-Wins)

最终状态:frame-1: { x: 100 }  ← Claude 的编辑胜出
或者
最终状态:frame-1: { x: 50 }   ← 用户的编辑胜出
(取决于具体的 CRDT 策略)

编辑器最终显示:双方都看到一致的状态

##🚀 部署架构

┌─────────────────────────────────────────────────────────────┐
│                   Railway (Container Hosting)               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────────┐  ┌──────────────┐  ┌─────────────┐  │
│  │  Web App         │  │  MCP Server  │  │ WS Server   │  │
│  │  apps/web        │  │  Rust Axum   │  │ Rust Tokio  │  │
│  │  (TanStack Start)│  │              │  │             │  │
│  │                  │  │ Port 3000    │  │ Port 3001   │  │
│  │ Port 80/443      │  │              │  │             │  │
│  └────────┬─────────┘  └──────┬───────┘  └─────┬───────┘  │
│           │                   │                │           │
│           └───────────────────┼────────────────┘           │
│                               │                             │
├───────────────────────────────┼─────────────────────────────┤
│                               │                             │
│                 ┌─────────────▼──────────────┐              │
│                 │   Neon Postgres            │              │
│                 │   (Managed Database)       │              │
│                 └─────────────────────────────┘              │
│                                                             │
│                 ┌─────────────────────────────┐             │
│                 │   Redis Cluster             │             │
│                 │   (Pub/Sub, Sessions)       │             │
│                 └─────────────────────────────┘             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Docker 部署

# crates/mcp-server/Dockerfile
FROM rust:latest

WORKDIR /app
COPY . .

RUN cargo build --release -p loora-mcp-server

CMD ["./target/release/loora-mcp-server"]

##📦 关键包导出 API

###@loora/canvas

// Document Model
import { CanvasDocument, CanvasNode, CanvasOperation } from '@loora/canvas/model'

// Engine(事务处理)
import { CanvasEngine, CanvasTransaction } from '@loora/canvas/engine'

// React 渲染
import { CanvasProvider, useCanvasNode, useCanvasContext } from '@loora/canvas/react'

// 导出
import { 
  exportCanvasAsHtml,
  exportCanvasAsReact,
  exportCanvasAsJson,
  renderElementToPng
} from '@loora/canvas/export'

// 导入
import { convertHtmlSnapshotToCanvas } from '@loora/canvas/import'

// Merge(CRDT)
import { mergeDocuments } from '@loora/canvas/merge'

###@loora/agent(MCP 工具)

import { canvasToolDefinitions } from '@loora/agent/canvas-tools'
// [
//   { name: 'createFrame', inputSchema: { ... } },
//   { name: 'patchNode', inputSchema: { ... } },
//   { name: 'moveNode', inputSchema: { ... } },
//   ...
// ]

import { repairLayout } from '@loora/agent/repair-layout'
// 修复布局冲突的工具函数

###@loora/rpc(Client API)

// oRPC 路由定义
import { appRouter } from '@loora/rpc'

// 使用示例
const client = createORpcClient({ url: '/api/rpc' })

// 读取
const design = await client.getDesignContext({
  designId: 'xyz',
  depth: 4
})

// 写入
const result = await client.applyTransaction({
  designId: 'xyz',
  transaction: { operations: [...] }
})

##🎓 总结:为什么这个架构很聪明?

###✅ 优点

  1. 分离关注点

    • Rust 处理高并发网络 I/O(MCP、WebSocket)
    • TypeScript 处理业务逻辑和 UI(Canvas Model、Rendering)
    • 两者不互相依赖
  2. 可导出性

    • Canvas 完全独立的数据模型 → 可导出为真实 HTML/CSS
    • 不依赖任何 Canvas API 或特定渲染引擎
    • Agent 可以理解和编辑纯数据结构
  3. 实时协作

    • WebSocket + Redis Pub/Sub = 低延迟多人编辑
    • CRDT Merge = 自动冲突解决,无需锁定
    • Rust 的性能保证高并发
  4. 跨平台

    • 同一套 React 组件 = Web + Desktop(Tauri)
    • 代码复用率高

###❌ 权衡

  1. 学习曲线陡峭

    • 需要理解 Canvas Model、Engine、CRDT、MCP、WebSocket 等概念
  2. 复杂性

    • Monorepo 中有 15+ 个包
    • 三种语言(Rust、TypeScript、SQL)
  3. 部署复杂

    • 至少需要 3 个独立服务(Web、MCP、WS)
    • Redis、Postgres 依赖

##🔗 关键文件速查表

功能文件位置
Document 结构定义packages/canvas/src/model.ts
事务引擎packages/canvas/src/engine.ts
React 渲染packages/canvas/src/react.tsx (3000+ 行)
HTML 导出packages/canvas/src/export.ts
HTML 导入packages/canvas/src/import.ts
Merge CRDTpackages/canvas/src/merge.ts
样式转换packages/canvas/src/style-css.ts
MCP 工具定义packages/agent/src/canvas-tools.ts
MCP 服务器crates/mcp-server/src/server.rs
WebSocket 服务crates/ws-server/src/server.rs
oRPC 路由packages/rpc/src/router.ts + mcp-procedures.ts
数据库packages/db/ (Drizzle schema)

希望这个解析清楚地回答了你的问题!Rust + SVG/DOM 并不是矛盾,而是一个精妙的分层设计:使用 Rust 处理网络基础设施的高并发需求,而用现代 Web 技术处理 UI 和业务逻辑。这样既获得了 Rust 的性能优势,又保留了 Web 的灵活性和可导出性。🚀

Avatar of xeno-sun
Xeno Sun
commented

renderElementToPng 详细解释这个函数是如何实现的

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

完美!现在我有了完整的理解。让我创建一个详细的解析文档:


#🖼️ renderElementToPng 详细实现解析

##📌 核心概念:DOM → SVG → Canvas → PNG

这个函数巧妙地利用了浏览器的多个 API 来实现无痛苦的 DOM 到 PNG 转换。关键在于使用 SVG 的 <foreignObject> 元素作为桥接

┌─────────────────────────────────────────────────────────────┐
│  HTML/SVG DOM Element (在浏览器中渲染的真实元素)            │
│  <div style="...">                                          │
│    <svg viewBox="...">                                      │
│      <path d="..." />                                       │
│    </svg>                                                   │
│  </div>                                                     │
└──────────────────┬──────────────────────────────────────────┘
                   │
                   │ 第1步:克隆 + 内联图片
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  克隆的 DOM Tree                                             │
│  所有外部 URL 的 <img> 转换为 data: URLs                    │
│  无法读取的图片用 1×1 透明 GIF 替代                         │
└──────────────────┬──────────────────────────────────────────┘
                   │
                   │ 第2步:序列化为 XML 字符串
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  XML 字符串                                                  │
│  "<div style=\"...\">                                       │
│     <svg viewBox=\"...\">                                   │
│       <path d=\"...\" />                                    │
│     </svg>                                                   │
│   </div>"                                                    │
└──────────────────┬──────────────────────────────────────────┘
                   │
                   │ 第3步:包装在 SVG 的 <foreignObject> 中
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  SVG 字符串                                                  │
│  "<svg xmlns=\"...\" width=\"800\" height=\"600\">          │
│    <foreignObject width=\"100%\" height=\"100%\">          │
│      <!-- 上面的 XML 字符串 -->                            │
│    </foreignObject>                                          │
│  </svg>"                                                     │
└──────────────────┬──────────────────────────────────────────┘
                   │
                   │ 第4步:转换为 Blob URL
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  Data URL: blob:http://localhost/a1b2c3d4                   │
│  MIME type: image/svg+xml                                    │
└──────────────────┬──────────────────────────────────────────┘
                   │
                   │ 第5步:加载为 Image 对象
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  HTMLImageElement                                            │
│  {                                                           │
│    src: 'blob:...',                                          │
│    width: 800,                                               │
│    height: 600,                                              │
│    data: /* 光栅化的 bitmap */                             │
│  }                                                           │
└──────────────────┬──────────────────────────────────────────┘
                   │
                   │ 第6步:绘制到 Canvas
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  Canvas 2D Context                                           │
│  context.drawImage(image, 0, 0, width, height)              │
│  /* 浏览器渲染 SVG 至 Canvas 的像素数据 */                  │
└──────────────────┬──────────────────────────────────────────┘
                   │
                   │ 第7步:转换为 PNG Data URL
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  PNG Data URL                                                │
│  "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA..."    │
└──────────────────────────────────────────────────────────────┘

##🔍 逐步代码解析

###第1步:验证环境 & 获取尺寸

export async function renderElementToPng(
  element: HTMLElement | SVGElement,
  options: CanvasPngRenderOptions = {},
) {
  // 验证是否在浏览器环境
  if (typeof document === 'undefined' || typeof XMLSerializer === 'undefined') {
    throw new Error('PNG rendering is available in a browser environment')
  }
  
  // 获取元素的实际视口尺寸(包括所有 CSS 变换)
  const bounds = element.getBoundingClientRect()
  // bounds = {
  //   top: 100,
  //   left: 50,
  //   width: 800,
  //   height: 600,
  //   right: 850,
  //   bottom: 700,
  //   x: 50,
  //   y: 100
  // }
  
  // 使用传入的尺寸,或者使用实际尺寸
  const width = Math.max(1, Math.ceil(options.width ?? bounds.width))   // 800
  const height = Math.max(1, Math.ceil(options.height ?? bounds.height)) // 600

为什么用 getBoundingClientRect()

  • 获取渲染后的尺寸(包括 zoom、scale 等 CSS 变换)
  • 而不是 offsetWidth(只有布局尺寸)或 clientWidth(不含 border)

###第2步:处理像素比和尺寸限制

  // 像素比:用于高 DPI 屏幕(Retina 等)
  const requestedPixelRatio = options.pixelRatio ?? window.devicePixelRatio ?? 1
  // devicePixelRatio 可能是 1(普通屏)、2(Retina)、3(高端手机)
  
  // 限制在合理范围内(防止浪费内存)
  const pixelRatio = Math.max(0.1, Math.min(requestedPixelRatio, 4))
  
  // 计算最终的 Canvas 像素尺寸
  const pixelWidth = Math.max(1, Math.round(width * pixelRatio))   // 800 × 2 = 1600
  const pixelHeight = Math.max(1, Math.round(height * pixelRatio)) // 600 × 2 = 1200
  
  // 安全检查:防止内存溢出
  const MAX_BROWSER_PNG_PIXELS = 32_000_000  // ~128 MiB 原始 RGBA
  const MAX_BROWSER_PNG_DIMENSION = 8_192
  
  if (
    !Number.isFinite(pixelWidth) ||
    !Number.isFinite(pixelHeight) ||
    pixelWidth > MAX_BROWSER_PNG_DIMENSION ||
    pixelHeight > MAX_BROWSER_PNG_DIMENSION ||
    pixelWidth * pixelHeight > MAX_BROWSER_PNG_PIXELS
  ) {
    throw new Error(
      'The PNG is too large to capture safely. Use a smaller scope or scale.',
    )
  }

例子:

用户要求:width=800, height=600, pixelRatio=2(Retina)
最终 Canvas:pixelWidth=1600, pixelHeight=1200
内存占用:1600 × 1200 × 4 bytes (RGBA) = 7.68 MB ✓

###第3步:克隆 DOM 树

  // 深克隆整个 DOM 树(包括所有子元素和事件监听器不会复制)
  const clone = element.cloneNode(true) as HTMLElement | SVGElement
  
  // 关键:内联所有外部 <img> 的 src
  // 这是为了防止 Canvas 被"污染"(taint canvas)
  for (const skipped of await inlineBrowserImages(clone)) {
    options.onSkippedImage?.(skipped)
  }

为什么要 inlineBrowserImages

这是整个函数最关键的部分!详见下面的完整解析。

###第4步:规范化克隆的位置

  // 移除所有影响位置的样式
  clone.style.position = 'relative'  // 防止 absolute 影响渲染
  clone.style.left = '0'
  clone.style.top = '0'
  clone.style.transform = 'none'     // 移除旋转、缩放等
  clone.style.margin = '0'            // 移除外边距
  
  // 如果克隆的是 SVG 元素,需要包装在 div 中
  const root =
    clone.namespaceURI === 'http://www.w3.org/2000/svg'
      ? document.createElement('div')  // 创建 HTML 容器
      : clone                           // HTML 元素直接用
  
  if (root !== clone) root.appendChild(clone)  // 如果是 SVG,加入 div
  root.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')

为什么要包装 SVG?

原始:<svg viewBox="0 0 100 100">...</svg>

需要变成:<div xmlns="http://www.w3.org/1999/xhtml">
            <svg viewBox="0 0 100 100">...</svg>
          </div>

原因:SVG 的 <foreignObject> 内容必须是 XHTML

###第5步:序列化为 XML 字符串

  // XMLSerializer 是浏览器 API,将 DOM 转换为 XML 字符串
  const markup = new XMLSerializer().serializeToString(root)
  // 输出例如:
  // "<div xmlns=\"http://www.w3.org/1999/xhtml\">
  //    <svg viewBox=\"0 0 100 100\" style=\"...\">
  //      <path d=\"M 10 10 L 90 90\" fill=\"none\" />
  //    </svg>
  // </div>"

它做了什么?

  • 遍历 DOM 树
  • 转换为 XML 字符串表示
  • 包含所有属性和样式(内联)

###第6步:包装在 SVG 的 <foreignObject>

  const svg = `<svg xmlns="http://www.w3.org/2000/svg" 
                    width="${width}" 
                    height="${height}" 
                    viewBox="0 0 ${width} ${height}">
                <foreignObject width="100%" height="100%">
                  ${markup}
                </foreignObject>
              </svg>`
  
  // 最终输出:
  // "<svg xmlns="http://www.w3.org/2000/svg" 
  //       width="800" 
  //       height="600" 
  //       viewBox="0 0 800 600">
  //   <foreignObject width="100%" height="100%">
  //     <div xmlns="http://www.w3.org/1999/xhtml">
  //       <svg viewBox="0 0 100 100" style="...">
  //         <path d="..." />
  //       </svg>
  //     </div>
  //   </foreignObject>
  // </svg>"

这是核心技巧!为什么?

问题为什么需要 <foreignObject>
HTML 不能直接光栅化<img src="data:text/html"> 不存在,浏览器不支持
SVG 可以被渲染浏览器知道如何渲染 SVG 为像素
<foreignObject>SVG 中的特殊元素,允许嵌入 HTML/XHTML 内容
结果浏览器渲染:SVG → 光栅化像素 → Canvas 可以读取
Without <foreignObject>:
  HTML → ❌ 无法直接转为图片

With <foreignObject>:
  HTML → wrapped in SVG → ✅ 可以转为图片

###第7步:创建 Blob URL

  // 将 SVG 字符串转换为 Blob
  const url = URL.createObjectURL(
    new Blob([svg], { type: 'image/svg+xml' })
  )
  // url = "blob:http://localhost/a1b2c3d4-e5f6-..." 
  // MIME type 告诉浏览器这是 SVG

为什么用 createObjectURL

  • 将数据转换为可以像真实网络资源一样加载的 URL
  • 浏览器的图片加载器会解析这个 SVG

###第8步:加载 SVG 为 Image 对象

  try {
    // 异步加载 SVG,浏览器会渲染它
    const image = await loadBrowserImage(url)
    // loadBrowserImage 的实现:
    function loadBrowserImage(src: string) {
      return new Promise<HTMLImageElement>((resolve, reject) => {
        const image = new Image()
        image.onload = () => resolve(image)  // ✓ 加载成功
        image.onerror = () => reject(new Error('...'))  // ✗ 加载失败
        image.src = src  // 触发加载
      })
    }

**关键时刻!**此时浏览器会:

  1. 解析 SVG
  2. 渲染其中的 <foreignObject> 内容
  3. 将整个内容光栅化为像素图像
  4. 将结果存储在 image 对象中

###第9步:创建 Canvas 并绘制

    // 创建 Canvas
    const canvas = document.createElement('canvas')
    canvas.width = pixelWidth    // 物理像素宽度
    canvas.height = pixelHeight  // 物理像素高度
    
    // 获取 2D 渲染上下文
    const context = canvas.getContext('2d')
    if (!context) throw new Error('Canvas export context is unavailable')
    
    // 缩放上下文以匹配像素比
    // 如果 pixelRatio=2,所有绘制操作都会放大 2 倍
    context.scale(pixelRatio, pixelRatio)
    
    // 重要:这里 width 和 height 是**逻辑**尺寸(不包括像素比)
    context.drawImage(image, 0, 0, width, height)
    // 浏览器实际绘制的是:
    // - 逻辑坐标 (0, 0) 到 (width, height)
    // - 但由于 scale(pixelRatio, pixelRatio),实际占用 pixelWidth × pixelHeight 像素

绘制过程的数学:

例:width=800, height=600, pixelRatio=2
  pixelWidth=1600, pixelHeight=1200

context.scale(2, 2)
  现在所有坐标都被乘以 2

context.drawImage(image, 0, 0, 800, 600)
  逻辑坐标:(0,0) 到 (800×600)
  实际像素:(0,0) 到 (1600×1200)  ← 因为乘以 2

结果:Retina 屏幕上显示清晰的图像 ✓

###第10步:转换为 PNG Data URL

    try {
      return canvas.toDataURL('image/png')
      // 输出:
      // "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA..."
    } catch {
      // Canvas 被污染了(被来自其他源的资源污染)
      throw new Error(
        'The browser refused to read the capture back because part of this design is loaded from another site.',
      )
    }
  } finally {
    // 清理 Blob URL,释放内存
    URL.revokeObjectURL(url)
  }
}

toDataURL 可能失败的原因:

// ✓ 安全的图片来源
<img src="/api/asset/123">        // 同源
<img src="data:image/png;base64,">  // 数据 URL

// ✗ 污染 Canvas 的图片来源
<img src="https://other-site.com/image.png">  // 跨域
<img src="https://cdn.example.com/pic.jpg">   // CORS 没有开启

// 结果:toDataURL() 抛出错误

这就是为什么 inlineBrowserImages 非常重要!


##🔴 深度分析:inlineBrowserImages 函数

这是整个转换过程中最复杂的部分。它的目标是:

将所有外部 <img> URL 转换为 data: URLs,这样 Canvas 就不会被污染。

export async function inlineBrowserImages(root: Element) {
  // 第1步:收集所有 <img> 标签
  const images = [
    ...root.querySelectorAll('img'),
    ...(root.tagName === 'IMG' ? [root as HTMLImageElement] : []),
  ]
  
  // 第2步:按 src 分组(避免重复加载相同的图片)
  const imagesBySource = new Map<string, HTMLImageElement[]>()
  for (const image of images) {
    // srcset 会引入远程候选项,必须移除
    image.removeAttribute('srcset')
    
    const source = image.getAttribute('src') ?? ''
    
    // 跳过空的和已经是 data: 的 URL
    if (!source || source.startsWith('data:')) continue
    
    const matching = imagesBySource.get(source) ?? []
    matching.push(image)
    imagesBySource.set(source, matching)
  }

  const entries = [...imagesBySource]
  const skipped: string[] = []
  let cursor = 0
  
  // 第3步:并发加载图片(最多 4 个并发)
  // 为什么限制并发?防止同时向浏览器请求太多资源
  const IMAGE_INLINE_CONCURRENCY = 4
  
  await Promise.all(
    Array.from(
      { length: Math.min(IMAGE_INLINE_CONCURRENCY, entries.length) },
      async () => {
        while (cursor < entries.length) {
          const [source, matching] = entries[cursor++]!
          const image = matching[0]!
          
          try {
            // 第4步:获取图片二进制数据
            const response = await fetch(image.src, {
              credentials: 'same-origin',  // 包含 cookie(同源)
            })
            
            if (!response.ok) {
              throw new Error(`Image responded ${response.status}`)
            }
            
            // 第5步:转换为 data: URL
            const dataUrl = await blobToDataUrl(await response.blob())
            
            // 第6步:更新所有相同 src 的 <img> 标签
            for (const target of matching) {
              target.setAttribute('src', dataUrl)
            }
          } catch {
            // 加载失败的处理:
            // - 记录失败的 URL
            skipped.push(...matching.map(() => source))
            
            // - 用 1×1 透明 GIF 替代(防止污染 Canvas)
            const BLANK_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
            for (const target of matching) {
              target.setAttribute('src', BLANK_IMAGE)
            }
          }
        }
      },
    ),
  )
  
  return skipped
}

###blobToDataUrl 如何工作?

async function blobToDataUrl(blob: Blob) {
  // 第1步:获取字节数据
  const bytes = new Uint8Array(await blob.arrayBuffer())
  
  // 第2步:转换为二进制字符串
  // ⚠️ 为什么分块?fromCharCode 有参数数量限制(栈溢出)
  let binary = ''
  for (let index = 0; index < bytes.length; index += 0x8000) {
    // 0x8000 = 32,768 字节 / 块
    binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000))
  }
  
  // 第3步:Base64 编码
  const b64 = btoa(binary)  // binary to ASCII
  
  // 第4步:生成 data: URL
  return `data:${blob.type || 'application/octet-stream'};base64,${b64}`
  // 例如:
  // data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA...
}

例子:

// 输入:PNG 图片
blob.type = 'image/png'
blob.arrayBuffer() = Uint8Array([137, 80, 78, 71, ...])

// 输出:
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."

###为什么需要这么复杂?

问题解决方案
跨域图片污染 Canvas必须将所有 URL 转换为 data: URLs
data: URL 太大使用 Base64 编码,嵌入在 URL 中
Base64 编码很慢预先转换,不用在每次导出时重新编码
srcset 会引入远程链接移除 srcset 属性
多个图片使用同一个 src使用 Map 分组,只加载一次,结果重用
加载太多图片会卡顿限制并发为 4
无法加载的图片用 1×1 透明 GIF 替代,而不是留下 URL

##📊 完整流程图(包含所有细节)

renderElementToPng(element, options)
│
├─ 第1步:验证环境
│  └─ typeof document !== 'undefined' ? ✓ : ✗ throw
│
├─ 第2步:计算尺寸
│  ├─ bounds = element.getBoundingClientRect()
│  ├─ width, height 从 options 或 bounds 中来
│  └─ pixelRatio 从 options 或 window.devicePixelRatio 中来
│
├─ 第3步:安全检查
│  └─ pixelWidth × pixelHeight ≤ 32,000,000 pixels ? ✓ : ✗ throw
│
├─ 第4步:克隆 DOM
│  └─ clone = element.cloneNode(true)
│
├─ 第5步:内联图片 ⭐ 关键
│  └─ inlineBrowserImages(clone)
│     ├─ 收集所有 <img> 标签
│     ├─ 按 src 分组
│     ├─ 并发加载(最多 4 个)
│     │  └─ fetch(image.src) → Blob → Base64 → data: URL
│     └─ 失败的用 1×1 GIF 替代
│
├─ 第6步:规范化位置
│  ├─ clone.style.position = 'relative'
│  ├─ clone.style.transform = 'none'
│  └─ 如果是 SVG,包装在 <div> 中
│
├─ 第7步:序列化为 XML
│  └─ markup = new XMLSerializer().serializeToString(root)
│
├─ 第8步:包装在 SVG 中
│  └─ svg = `<svg ...><foreignObject>...</foreignObject></svg>`
│
├─ 第9步:创建 Blob URL
│  └─ url = URL.createObjectURL(new Blob([svg]))
│
├─ 第10步:加载为 Image ⭐ 浏览器渲染发生在这里
│  └─ image = await loadBrowserImage(url)
│     └─ 浏览器:渲染 SVG → 光栅化为像素
│
├─ 第11步:创建 Canvas
│  ├─ canvas.width = pixelWidth
│  ├─ canvas.height = pixelHeight
│  └─ context = canvas.getContext('2d')
│
├─ 第12步:绘制图像
│  ├─ context.scale(pixelRatio, pixelRatio)
│  └─ context.drawImage(image, 0, 0, width, height)
│
├─ 第13步:转换为 PNG
│  └─ return canvas.toDataURL('image/png')
│
└─ 第14步:清理
   └─ URL.revokeObjectURL(url)

##🧪 测试案例分析

###测试 1:跨域图片污染

it('blanks an image it cannot read rather than tainting the capture', async () => {
  globalThis.fetch = (async (input) => {
    // 模拟 CORS 错误
    if (String(input).includes('cdn.example.com')) throw new Error('CORS')
    return new Response(new Uint8Array([137, 80, 78, 71]), {
      headers: { 'Content-Type': 'image/png' },
    })
  })

  const host = document.createElement('div')
  host.innerHTML =
    '<img src="https://cdn.example.com/hero.png" srcset="...">'+  // ✗ 无法读取
    '<img src="/api/asset/a1">'                                    // ✓ 可以读取

  const skipped = await inlineBrowserImages(host)

  expect(skipped).toEqual(['https://cdn.example.com/hero.png'])
  
  // 无法读取的图片被替换为 BLANK_IMAGE
  expect(images[0].src).toMatch(/^data:image\/gif;base64,/)
  
  // 可以读取的图片被转为 data: URL
  expect(images[1].src).toMatch(/^data:image\/png/)
})

###测试 2:重复的图片只加载一次

it('fetches a repeated image once before reusing its data URL', async () => {
  let fetches = 0
  globalThis.fetch = async () => {
    fetches += 1
    return new Response(...)
  }

  const host = document.createElement('div')
  host.innerHTML = '<img src="/api/asset/a1"><img src="/api/asset/a1">'

  await inlineBrowserImages(host)
  
  // 同一个 URL 只加载一次
  expect(fetches).toBe(1)
  
  // 两个 <img> 都被更新
  const sources = [...host.querySelectorAll('img')].map(img => img.src)
  expect(sources[0]).toBe(sources[1])  // 相同的 data: URL
})

###测试 3:限制并发

it('bounds simultaneous image reads during a capture', async () => {
  let active = 0
  let maximum = 0
  
  globalThis.fetch = async () => {
    active += 1
    maximum = Math.max(maximum, active)
    await new Promise(resolve => setTimeout(resolve, 0))
    active -= 1
    return new Response(...)
  }

  const host = document.createElement('div')
  // 12 个不同的图片
  host.innerHTML = Array.from(
    { length: 12 },
    (_, i) => `<img src="/api/asset/${i}">`,
  ).join('')

  await inlineBrowserImages(host)
  
  // 最多同时 4 个请求
  expect(maximum).toBe(4)
})

###测试 4:尺寸限制

it('refuses a PNG allocation large enough to exhaust browser memory', async () => {
  const host = document.createElement('div')
  await expect(
    renderElementToPng(host, {
      width: 10_000,
      height: 10_000,
      pixelRatio: 2,
      // 总像素:10000 × 10000 × 2 × 2 = 400,000,000 > 32,000,000 ✗
    }),
  ).rejects.toThrow('too large')
})

##⚡ 关键性能优化

###1. 缓存已加载的图片

// 相同 src 的图片只加载一次
const imagesBySource = new Map<string, HTMLImageElement[]>()

###2. 并发加载限制

// 最多 4 个并发请求,防止浏览器过载
const IMAGE_INLINE_CONCURRENCY = 4

###3. 分块编码

// Base64 编码时分块处理,防止栈溢出
for (let index = 0; index < bytes.length; index += 0x8000) {
  binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000))
}

###4. 尺寸限制

// 防止内存溢出
const MAX_BROWSER_PNG_PIXELS = 32_000_000  // ~128 MiB
const MAX_BROWSER_PNG_DIMENSION = 8_192

###5. 资源清理

// 完成后释放 Blob URL
finally {
  URL.revokeObjectURL(url)
}

##🎯 总结:为什么这个设计这么聪明?

方面为什么这样设计
SVG + <foreignObject>HTML 无法直接光栅化,但 SVG 可以。<foreignObject> 允许在 SVG 中嵌入 HTML。
XMLSerializer将动态 DOM 转换为静态字符串,避免每个浏览器重新渲染。
Blob URL让字符串数据成为可加载的资源,触发浏览器的渲染流程。
Image 对象浏览器在加载 Image 时自动渲染 SVG,转换为像素数据。
Canvas.drawImage将光栅化的 Image 绘制到 Canvas,可以转换为 PNG。
Canvas.toDataURL最后一步,转换为 Base64 编码的 PNG。
内联图片防止跨域图片污染 Canvas,使转换可靠。
pixelRatio 支持在高 DPI 屏幕上生成清晰的 PNG。

##🚀 使用示例

import { renderElementToPng } from '@loora/canvas'

// 简单用法
const element = document.querySelector('[data-loora-page]')!
const png = await renderElementToPng(element)
// → "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."

// 高级用法
const png = await renderElementToPng(element, {
  width: 1920,
  height: 1080,
  pixelRatio: 2,  // Retina 屏幕
  onSkippedImage: (src) => {
    console.warn(`Could not inline image: ${src}`)
  }
})

// 下载 PNG
const a = document.createElement('a')
a.href = png
a.download = 'design.png'
a.click()

这个函数是 Loora 整个架构的一个完美体现:利用现代浏览器 API,聪明地解决复杂问题,而无需任何外部库或 Canvas 黑魔法! 🎨✨


END OF POST

How does loora's tech stack work with Rust, GUI, SVG and HTML? — lassejlv/loora