dsh-drawai 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # dsh-drawai
6
6
 
7
- [![GitHub stars](https://img.shields.io/github/stars/fourzkw/dsh-drawai?style=social)](https://github.com/fourzkw/dsh-drawai) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
+ [![GitHub stars](https://img.shields.io/github/stars/fourzkw/dsh-drawai?style=social)](https://github.com/fourzkw/dsh-drawai) [![npm downloads](https://img.shields.io/npm/dt/dsh-drawai)](https://www.npmjs.com/package/dsh-drawai) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
8
8
 
9
9
  ## 功能一览
10
10
 
@@ -56,7 +56,7 @@ dsh plugin --profile web add github:fourzkw/dsh-drawai
56
56
 
57
57
  | 入口 | 你能得到什么 |
58
58
  |---|---|
59
- | **右侧栏画布** | 打开或新建画布、画节点与连线、就地改字、图层、分区、顺序、多选对齐、查找、微移、多页切换、复制粘贴、导出 SVG / PNG(含导出选中) |
59
+ | **右侧栏画布** | 打开或新建画布、画节点与连线、就地改字、图层、分区、顺序、多选对齐、查找、微移、多页切换、复制粘贴、导出 SVG / PNG / 透明 PNG(4×,含导出选中) |
60
60
  | **`diagram_apply`** | 让模型按 ops 改图:增删改、重接边、复制、排序、分层、导出;支持 `ids` 批量、`as` 别名、`expectRevision` |
61
61
  | **`diagram_read`** | 让模型看见图的结构与样式、图层与父级、页面尺寸、你此刻的选区、文件指纹 `revision` |
62
62
  | **选中与回退** | 你选中什么模型就知道什么;模型的每一轮改动都能在界面上退回去 |
@@ -78,7 +78,7 @@ dsh plugin --profile web add github:fourzkw/dsh-drawai
78
78
  | **图层** | 列出 / 新建 / 显示隐藏 / 锁定 / 重命名 / 删除(合并到邻层)/ 选中移层 / 设为当前层;隐藏与锁定写入文件 |
79
79
  | **顺序** | 置顶 / 上移 / 下移 / 置底 —— 写回时**真的改动文件里单元的先后**(否则重新打开就变回去了) |
80
80
  | **自定义数据** | 右键「编辑数据」,按 `key=value` 一行一项改单元属性;按 user object 存回文件,drawio 打开不会丢 |
81
- | **导出** | SVG / PNG(2×);「看一眼画布效果」走 DSH 附件服务,**不在工作区落文件** |
81
+ | **导出** | SVG / PNG(2×) / 透明 PNG(4×);「看一眼画布效果」走 DSH 附件服务,**不在工作区落文件** |
82
82
  | **回退** | 「编辑 → 撤销 AI 改动」最多可以连着退 8 步 |
83
83
 
84
84
  ## 🤖 两个工具 / The two tools
@@ -211,7 +211,7 @@ id / layer / parent、选区能上报给模型、写回是逐单元的、画布
211
211
  - **多页**:可切换编辑当前页(视图菜单);其它页保存时原样保留。AI 可用 `diagram_read` / `diagram_apply` 的 `page` 参数指定页。多页的**新建页**尚未实现。
212
212
  - 独立 `edgeLabel` 单元只读;`entityRelation` 等其它 edgeStyle、箭头字形、旋转 / 翻转 / 透明、stencil 形状只保留、不渲染;`.drawio.svg` / `.png` / `.html` 内嵌载体不支持。
213
213
  - **图片**:内嵌 SVG(`data:image/svg+xml,…`)与内嵌位图(PNG/JPEG/GIF/WebP data URI)都会画出来;**外链 / 相对路径**图片仍按矩形占位,单元原样保留(工作区磁盘上的图片文件画布直接读不到——要复刻请用 `traceImage`)。
214
- - 导出只有 SVG 与 PNG(2×):没有 PDF。
214
+ - 导出只有 SVG、PNG(2×) 与透明 PNG(4×):没有 PDF。
215
215
  - 交互细节:对齐辅助线只跟节点比(不与折点、端点对齐);单条选中的连线不能复制。
216
216
 
217
217
  完整的取舍与实现细节在 [docs/design.md](docs/design.md)。
package/lib/client.js CHANGED
@@ -7677,6 +7677,7 @@ window.__ModuleLoader__.load({
7677
7677
  if (junk[i].parentNode !== null) junk[i].parentNode.removeChild(junk[i])
7678
7678
  }
7679
7679
  const ids = opts !== undefined && opts !== null && Array.isArray(opts.ids) ? opts.ids : null
7680
+ const transparent = opts !== undefined && opts !== null && opts.transparent === true
7680
7681
  let boundsDoc = current
7681
7682
  if (ids !== null && ids.length > 0) {
7682
7683
  const filtered = docFilteredToIds(current, ids)
@@ -7716,22 +7717,35 @@ window.__ModuleLoader__.load({
7716
7717
  // 就是严格模式)会因为"用了未声明的前缀"整份丢弃:导出的 SVG/PNG 里那张图凭空消失。
7717
7718
  clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
7718
7719
  clone.removeAttribute('style')
7719
- // 网格纸在实时视图里只铺满"当前视口";导出要铺满整图,否则缩放状态下网格会断掉。
7720
- const grid = clone.querySelector('rect[fill^="url(#drawai-grid-major"]')
7721
- if (grid !== null) {
7722
- grid.setAttribute('x', String(bounds.minX - pad))
7723
- grid.setAttribute('y', String(bounds.minY - pad))
7724
- grid.setAttribute('width', String(w))
7725
- grid.setAttribute('height', String(h))
7726
- }
7727
- // 网格图案是半透明的:不加一层底色的话,PNG 出来是透明背景。
7728
- const paper = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
7729
- paper.setAttribute('x', String(bounds.minX - pad))
7730
- paper.setAttribute('y', String(bounds.minY - pad))
7731
- paper.setAttribute('width', String(w))
7732
- paper.setAttribute('height', String(h))
7733
- paper.setAttribute('fill', mode === 'dark' ? '#1b1b1b' : '#ffffff')
7734
- clone.insertBefore(paper, clone.firstChild)
7720
+ // 视口底:网格纸(url(#drawai-grid-major…))或关网格时的实色纸。它是 svg 下第一个 <rect>。
7721
+ let pageRect = null
7722
+ for (let i = 0; i < clone.childNodes.length; i += 1) {
7723
+ const child = clone.childNodes[i]
7724
+ if (child.nodeType === 1 && String(child.nodeName).toLowerCase() === 'rect') {
7725
+ pageRect = child
7726
+ break
7727
+ }
7728
+ }
7729
+ if (transparent) {
7730
+ // 透明导出:底纸/网格都要拿掉,否则 alpha 通道会被盖成不透明。
7731
+ if (pageRect !== null && pageRect.parentNode !== null) pageRect.parentNode.removeChild(pageRect)
7732
+ } else {
7733
+ // 网格纸在实时视图里只铺满"当前视口";导出要铺满整图,否则缩放状态下网格会断掉。
7734
+ if (pageRect !== null) {
7735
+ pageRect.setAttribute('x', String(bounds.minX - pad))
7736
+ pageRect.setAttribute('y', String(bounds.minY - pad))
7737
+ pageRect.setAttribute('width', String(w))
7738
+ pageRect.setAttribute('height', String(h))
7739
+ }
7740
+ // 网格图案是半透明的:不加一层底色的话,PNG 出来是透明背景。
7741
+ const paper = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
7742
+ paper.setAttribute('x', String(bounds.minX - pad))
7743
+ paper.setAttribute('y', String(bounds.minY - pad))
7744
+ paper.setAttribute('width', String(w))
7745
+ paper.setAttribute('height', String(h))
7746
+ paper.setAttribute('fill', mode === 'dark' ? '#1b1b1b' : '#ffffff')
7747
+ clone.insertBefore(paper, clone.firstChild)
7748
+ }
7735
7749
  return { node: clone, width: w, height: h }
7736
7750
  }
7737
7751
 
@@ -7837,12 +7851,16 @@ window.__ModuleLoader__.load({
7837
7851
  setTimeout(() => URL.revokeObjectURL(url), 2000)
7838
7852
  }
7839
7853
 
7840
- function downloadPng(node, width, height, filename) {
7854
+ function downloadPng(node, width, height, filename, opts) {
7841
7855
  const markup = new XMLSerializer().serializeToString(node)
7842
7856
  const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }))
7843
7857
  const image = new Image()
7844
7858
  image.onload = () => {
7845
- const scale = 2
7859
+ // 默认 2×;透明高质量导出用 4×。过大时压到浏览器常见 canvas 上限,避免整图失败。
7860
+ let scale = opts !== undefined && opts !== null && typeof opts.scale === 'number' && opts.scale > 0 ? opts.scale : 2
7861
+ const transparent = opts !== undefined && opts !== null && opts.transparent === true
7862
+ const maxEdge = 8192
7863
+ while (scale > 1 && (width * scale > maxEdge || height * scale > maxEdge)) scale = scale / 2
7846
7864
  const canvas = document.createElement('canvas')
7847
7865
  canvas.width = Math.max(1, Math.round(width * scale))
7848
7866
  canvas.height = Math.max(1, Math.round(height * scale))
@@ -7851,8 +7869,10 @@ window.__ModuleLoader__.load({
7851
7869
  URL.revokeObjectURL(url)
7852
7870
  return
7853
7871
  }
7854
- context.fillStyle = mode === 'dark' ? '#1b1b1b' : '#ffffff'
7855
- context.fillRect(0, 0, canvas.width, canvas.height)
7872
+ if (transparent !== true) {
7873
+ context.fillStyle = mode === 'dark' ? '#1b1b1b' : '#ffffff'
7874
+ context.fillRect(0, 0, canvas.width, canvas.height)
7875
+ }
7856
7876
  context.drawImage(image, 0, 0, canvas.width, canvas.height)
7857
7877
  URL.revokeObjectURL(url)
7858
7878
  canvas.toBlob((blob) => {
@@ -9274,19 +9294,23 @@ window.__ModuleLoader__.load({
9274
9294
  // 导出。刻意放在 effect 里跑:按钮先清掉选中,等这一帧渲染完(DOM 里没有手柄和选中框了)
9275
9295
  // 再序列化,产物才干净。若在点击回调里直接导,会把手柄一起拍进去。
9276
9296
  //
9277
- // 两种触发形态:菜单里点的是字符串('svg' / 'png')→ 浏览器下载;AI 要的是对象
9278
- // (`{format, requestId, name}`)→ svg 把文本回执给宿主落盘、png 仍走浏览器下载。
9279
- // 导出选中:`{format, ids}` —— 不清选区(还要用 ids),从克隆里剔掉未选中的单元。
9297
+ // 两种触发形态:菜单里点的是字符串('svg' / 'png')→ 浏览器下载;对象形态
9298
+ // (`{format, requestId?, name?, ids?, transparent?, scale?}`)→ 菜单的选区/透明导出,
9299
+ // 或 AI 的 `{format, requestId, name}`(svg 回执落盘、png 仍走浏览器下载)。
9280
9300
  React.useEffect(() => {
9281
9301
  if (exportRequest === null) return
9282
9302
  const request = exportRequest
9283
9303
  setExportRequest(null)
9284
- const fromAi = typeof request === 'object' && request !== null
9285
- const format = fromAi ? request.format : request
9286
- const requestId = fromAi && typeof request.requestId === 'string' ? request.requestId : null
9287
- const askedName = fromAi && typeof request.name === 'string' && request.name.length > 0 ? request.name : null
9288
- const exportIds = fromAi && Array.isArray(request.ids) ? request.ids : null
9289
- const built = buildExportSvg(exportIds !== null ? { ids: exportIds } : undefined)
9304
+ const fromObj = typeof request === 'object' && request !== null
9305
+ const format = fromObj ? request.format : request
9306
+ const requestId = fromObj && typeof request.requestId === 'string' ? request.requestId : null
9307
+ const askedName = fromObj && typeof request.name === 'string' && request.name.length > 0 ? request.name : null
9308
+ const exportIds = fromObj && Array.isArray(request.ids) ? request.ids : null
9309
+ const transparent = fromObj && request.transparent === true
9310
+ const scale = fromObj && typeof request.scale === 'number' && request.scale > 0 ? request.scale : 2
9311
+ const built = buildExportSvg(
9312
+ exportIds !== null || transparent ? { ids: exportIds !== null ? exportIds : undefined, transparent: transparent } : undefined,
9313
+ )
9290
9314
  if (built === null) {
9291
9315
  if (requestId !== null) reportExportResult(requestId, format, { ok: false, error: '画布还没准备好,导出没做成' })
9292
9316
  else if (exportIds !== null) setSaveNote('导出选中失败:选区里没有可导出的单元')
@@ -9297,7 +9321,9 @@ window.__ModuleLoader__.load({
9297
9321
  const name =
9298
9322
  askedName !== null
9299
9323
  ? askedName
9300
- : String(source).split(/[\\/]/).pop().replace(/\.drawio$/i, '') + (exportIds !== null ? '-selection' : '')
9324
+ : String(source).split(/[\\/]/).pop().replace(/\.drawio$/i, '') +
9325
+ (exportIds !== null ? '-selection' : '') +
9326
+ (transparent ? '-transparent' : '')
9301
9327
  if (format === 'svg') {
9302
9328
  const markup = new XMLSerializer().serializeToString(built.node)
9303
9329
  // AI 要的是"落成工作区里的一个文件":把文本回执给宿主,由它写在 .drawio 旁边。
@@ -9309,7 +9335,7 @@ window.__ModuleLoader__.load({
9309
9335
  downloadBlob(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), name + '.svg')
9310
9336
  return
9311
9337
  }
9312
- downloadPng(built.node, built.width, built.height, name + '.png')
9338
+ downloadPng(built.node, built.width, built.height, name + '.png', { scale: scale, transparent: transparent })
9313
9339
  // PNG 走浏览器下载(AI 触发的也一样):宿主写二进制得另开通道,而"点一次导出"就有的东西
9314
9340
  // 不值得为它搬 base64。下载成不成浏览器不给回执,所以这里只报"已经交给下载"。
9315
9341
  if (requestId !== null) {
@@ -11254,7 +11280,15 @@ window.__ModuleLoader__.load({
11254
11280
  setSelectedIds([])
11255
11281
  setExportRequest('png')
11256
11282
  },
11257
- { hint: '位图,适合贴到文档里', disabled: empty },
11283
+ { hint: '位图,白/深色底,适合贴到文档里', disabled: empty },
11284
+ ),
11285
+ item(
11286
+ '导出透明 PNG(4×)',
11287
+ () => {
11288
+ setSelectedIds([])
11289
+ setExportRequest({ format: 'png', transparent: true, scale: 4 })
11290
+ },
11291
+ { hint: '高质量透明背景,适合叠到幻灯片/海报上', disabled: empty },
11258
11292
  ),
11259
11293
  item(
11260
11294
  '导出选中为 SVG',
@@ -11264,7 +11298,12 @@ window.__ModuleLoader__.load({
11264
11298
  item(
11265
11299
  '导出选中为 PNG',
11266
11300
  () => setExportRequest({ format: 'png', ids: selectedIds.slice() }),
11267
- { hint: '只导出当前选区', disabled: empty || selectedIds.length === 0 },
11301
+ { hint: '只导出当前选区(白/深色底)', disabled: empty || selectedIds.length === 0 },
11302
+ ),
11303
+ item(
11304
+ '导出选中为透明 PNG',
11305
+ () => setExportRequest({ format: 'png', ids: selectedIds.slice(), transparent: true, scale: 4 }),
11306
+ { hint: '选区透明背景,4× 分辨率', disabled: empty || selectedIds.length === 0 },
11268
11307
  ),
11269
11308
  ],
11270
11309
  },
package/lib/index.js CHANGED
@@ -19,7 +19,13 @@ import { readdir } from 'node:fs/promises'
19
19
  import { isAbsolute, resolve as resolvePath, join as joinPath } from 'node:path'
20
20
  import { applyDocToMxfile, buildMxfile, contentHash, parseMxfile } from './mxfile.js'
21
21
  import { decodeRaster } from './raster.js'
22
- import { TRACE_DEFAULTS, traceRasterToSvg } from './trace-image.js'
22
+ import {
23
+ TRACE_DEFAULTS,
24
+ TRACE_PRESET_NAMES,
25
+ resolveTraceOptions,
26
+ summarizeTraceForAi,
27
+ traceRasterToSvg,
28
+ } from './trace-image.js'
23
29
  import {
24
30
  ARROW_KINDS,
25
31
  DASH_KINDS,
@@ -408,8 +414,17 @@ function fontColorFieldOf(style) {
408
414
  const DEFAULT_SVG_W = 80
409
415
  const DEFAULT_SVG_H = 80
410
416
 
411
- /** 一段内嵌 SVG 标记的长度上限。超了直接拒:一个单元格几十万字符会把文件和上下文一起撑爆。 */
412
- const SVG_MARKUP_LIMIT = 60000
417
+ /**
418
+ * 一段内嵌 SVG 标记的长度上限。超了直接拒:一个单元格几十万字符会把文件和上下文一起撑爆。
419
+ *
420
+ * 为什么是 250000:**这是整图矢量化精细度的直接瓶颈**。实测一张 1024×1536 的立绘,
421
+ * grid 800 的原始产出就要 249630 字符 —— 卡在 60000 时管线只能压掉 5.4 倍(eps 抬到 3.2、
422
+ * 小环大量丢弃,环保留从 10235 掉到 2049),于是"脸和细节糊"就成了必然。
423
+ * 放到 250000:同一张图 grid 1000 只压 2 次、环保留 5455(翻 2.7 倍)。
424
+ * 再往上(不压缩约 327000 字符)文件会到 440KB/节点、读回时上下文开销约 8~9 万 token,
425
+ * 收益递减,所以停在 250000。要更细请用 cropX/cropY/cropW/cropH 做局部(那不受这个上限的边际影响)。
426
+ */
427
+ const SVG_MARKUP_LIMIT = 250000
413
428
  /** traceImage 能读的图片字节上限(够任何截图,又不至于让一次调用把宿主内存吃干)。 */
414
429
  const TRACE_IMAGE_MAX_BYTES = 32 * 1024 * 1024
415
430
  /** diagram_read 里回显 svg 的长度上限(超了截断并注明总长)。 */
@@ -625,6 +640,54 @@ function styleNote(style) {
625
640
  return bits.length === 0 ? '' : ' [' + bits.join(', ') + ']'
626
641
  }
627
642
 
643
+ /**
644
+ * 解析 traceImage 的裁剪矩形(cropX / cropY / cropW / cropH,都相对原图像素坐标)。
645
+ *
646
+ * 为什么要裁剪:矢量化的工作分辨率上限由 grid(默认 499px 宽)决定 —— 整张图画进去时,
647
+ * 一张 1024 宽的立绘里"脸"只有约 38px 宽、眼睛 3~4px,量化后五官必然消失。
648
+ * 裁出局部再描,同一块区域就占满工作图宽度,脸能有 190px —— 这是唯一能真正改善
649
+ * "面部特征缺失"的手段(调 colors / protectLuma 都无效:已经实测扫过参数)。
650
+ *
651
+ * 只写 cropW/cropH 时默认从左上角裁;一个字段都不给 = 整图(返回 null)。
652
+ * 裁剪后的 SVG viewBox 会平移回原图像素系,所以节点位置仍与原图对齐。
653
+ */
654
+ function traceCropRect(op, where) {
655
+ const given = ['cropX', 'cropY', 'cropW', 'cropH'].filter((key) => has(op, key))
656
+ if (given.length === 0) return null
657
+ const box = {
658
+ x: Math.round(numberOr(op.cropX, 0)),
659
+ y: Math.round(numberOr(op.cropY, 0)),
660
+ w: Math.round(numberOr(op.cropW, 0)),
661
+ h: Math.round(numberOr(op.cropH, 0)),
662
+ }
663
+ if (box.w <= 0 || box.h <= 0) {
664
+ throw new Error(
665
+ where + ': 裁剪区域要给出正数 cropW / cropH(当前 ' + box.w + '×' + box.h +
666
+ ');cropX / cropY 缺省为 0(左上角)。只给 cropX/cropY 不是裁剪,给全 cropX/cropY/cropW/cropH。',
667
+ )
668
+ }
669
+ if (box.x < 0 || box.y < 0) {
670
+ throw new Error(where + ': cropX / cropY 不能是负数(当前 ' + box.x + ',' + box.y + ')')
671
+ }
672
+ return box
673
+ }
674
+
675
+ /** 裁出 raster 的 [x, x+w) × [y, y+h) 一块(RGBA8,逐行拷)。越界会报错,不静默补白。 */
676
+ function cropRaster(raster, box, where) {
677
+ if (box.x + box.w > raster.width || box.y + box.h > raster.height) {
678
+ throw new Error(
679
+ where + ': 裁剪区域超出图片范围(图片 ' + raster.width + '×' + raster.height +
680
+ ',要裁 x=' + box.x + ' y=' + box.y + ' ' + box.w + '×' + box.h + ')',
681
+ )
682
+ }
683
+ const out = new Uint8Array(box.w * box.h * 4)
684
+ for (let row = 0; row < box.h; row += 1) {
685
+ const from = ((box.y + row) * raster.width + box.x) * 4
686
+ out.set(raster.data.subarray(from, from + box.w * 4), row * box.w * 4)
687
+ }
688
+ return { width: box.w, height: box.h, data: out }
689
+ }
690
+
628
691
  /**
629
692
  * 施加 ops。语义校验在这里:任何指向不存在节点的边、任何未知 id,都直接抛错并列出已知节点,
630
693
  * 于是失败发生在写盘之前 —— 不会画出半张烂图。
@@ -762,6 +825,7 @@ async function applyOps(doc, ops, out, opctx) {
762
825
  //
763
826
  // 这一步要读像素(I/O),所以整条 applyOps 是 async 的 —— 别把它当成同步 op 来加。
764
827
  const where = 'ops[' + i + '] traceImage'
828
+ const traceCrop = traceCropRect(op, where)
765
829
  if (opctx === undefined || opctx === null) throw new Error(where + ': 这个上下文不支持读图片(内部错误)')
766
830
  const rawImage = typeof op.image === 'string' && op.image.length > 0 ? op.image : undefined
767
831
  if (rawImage === undefined) {
@@ -774,26 +838,65 @@ async function applyOps(doc, ops, out, opctx) {
774
838
  } catch (error) {
775
839
  throw new Error(where + ': ' + messageOf(error))
776
840
  }
777
- const traced = traceRasterToSvg(raster, {
778
- colors: numberOr(op.colors, TRACE_DEFAULTS.colors),
779
- grid: numberOr(op.grid, TRACE_DEFAULTS.grid),
780
- scale: numberOr(op.scale, TRACE_DEFAULTS.scale),
781
- epsilon: numberOr(op.epsilon, TRACE_DEFAULTS.epsilon),
782
- precision: numberOr(op.precision, TRACE_DEFAULTS.precision),
783
- minArea: numberOr(op.minArea, TRACE_DEFAULTS.minArea),
784
- bgBright: numberOr(op.bgBright, TRACE_DEFAULTS.bgBright),
785
- bgTol: numberOr(op.bgTol, TRACE_DEFAULTS.bgTol),
786
- seeds: numberOr(op.seeds, TRACE_DEFAULTS.seeds),
787
- maxBytes: Math.min(numberOr(op.maxBytes, TRACE_DEFAULTS.maxBytes), SVG_MARKUP_LIMIT - 2000),
788
- })
841
+ // 裁剪:只把这一块交给矢量化,于是这块区域能占满整个工作分辨率(见 traceCropRect 的说明)。
842
+ let tracedFrom = raster
843
+ if (traceCrop !== null) {
844
+ try {
845
+ tracedFrom = cropRaster(raster, traceCrop, where)
846
+ } catch (error) {
847
+ throw new Error(where + ': ' + messageOf(error))
848
+ }
849
+ }
850
+ // 显式字段覆盖预设:没写的键用 undefined,让 resolveTraceOptions / 默认值接手
851
+ // 没给 maxBytes 时按上限走:细则由这个预算决定(预算越小,管线越要抬 epsilon、丢小环)
852
+ const rawTraceBudget = has(op, 'maxBytes')
853
+ ? numberOr(op.maxBytes, TRACE_DEFAULTS.maxBytes)
854
+ : SVG_MARKUP_LIMIT - 2000
855
+ const rawTrace = {
856
+ preset: typeof op.preset === 'string' ? op.preset : TRACE_DEFAULTS.preset,
857
+ colors: has(op, 'colors') ? op.colors : undefined,
858
+ colorsDark: has(op, 'colorsDark') ? op.colorsDark : undefined,
859
+ colorsFill: has(op, 'colorsFill') ? op.colorsFill : undefined,
860
+ grid: has(op, 'grid') ? op.grid : undefined,
861
+ gridH: has(op, 'gridH') ? op.gridH : undefined,
862
+ scale: has(op, 'scale') ? op.scale : undefined,
863
+ epsilon: has(op, 'epsilon') ? op.epsilon : undefined,
864
+ precision: has(op, 'precision') ? op.precision : undefined,
865
+ minArea: has(op, 'minArea') ? op.minArea : undefined,
866
+ minRegionArea: has(op, 'minRegionArea') ? op.minRegionArea : undefined,
867
+ protectLuma: has(op, 'protectLuma') ? op.protectLuma : undefined,
868
+ majorityContrast: has(op, 'majorityContrast') ? op.majorityContrast : undefined,
869
+ bgBright: has(op, 'bgBright') ? op.bgBright : undefined,
870
+ bgTol: has(op, 'bgTol') ? op.bgTol : undefined,
871
+ bgColor: has(op, 'bgColor') ? op.bgColor : undefined,
872
+ seeds: has(op, 'seeds') ? op.seeds : undefined,
873
+ maxBytes: Math.min(rawTraceBudget, SVG_MARKUP_LIMIT - 2000),
874
+ }
875
+ // 清掉 undefined,避免 Object.assign 把预设盖成 undefined
876
+ for (const key of Object.keys(rawTrace)) {
877
+ if (rawTrace[key] === undefined) delete rawTrace[key]
878
+ }
879
+ let resolved
880
+ try {
881
+ resolved = resolveTraceOptions(rawTrace)
882
+ } catch (error) {
883
+ throw new Error(where + ': ' + messageOf(error))
884
+ }
885
+ const traced = traceRasterToSvg(tracedFrom, resolved.options)
789
886
  const metrics = traced.metrics
790
887
  if (traced.svg.length > SVG_MARKUP_LIMIT) {
791
888
  throw new Error(
792
889
  where + ': 描出来的 SVG 太长(' + traced.svg.length + ' 字符,上限 ' + SVG_MARKUP_LIMIT +
793
- ')。调小 grid(当前 ' + metrics.work.width + ')、调大 epsilon(当前 ' + op.epsilon + ')、或减少 colors。',
890
+ ')。调小 grid(当前 ' + metrics.work.width + ')、调大 epsilon、减少 colors,或 preset:"simple"。' +
891
+ (metrics.tuneHint ? ' 建议:' + metrics.tuneHint : ''),
794
892
  )
795
893
  }
796
- // 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h 显式给了就用给的
894
+ // 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h 显式给了就用给的。
895
+ //
896
+ // 裁剪块的 SVG 里路径坐标是**裁剪局部**的(viewBox = 0 0 cropW cropH)—— 别去平移 viewBox:
897
+ // 路径不会跟着走,内容会整体跑出可视区(实测渲染出空白)。要让它落在原图对应的位置上,
898
+ // 靠的是节点框的**比例**:节点 w/h ≈ 原图尺寸 × (裁剪块尺寸 / 该块缩放),
899
+ // 配合 imageAspect=0 拉伸铺满,局部坐标系就正好铺到原图那一块。
797
900
  const w = numberOr(op.w, raster.width)
798
901
  const h = numberOr(op.h, raster.height)
799
902
  const synthetic = {
@@ -801,7 +904,7 @@ async function applyOps(doc, ops, out, opctx) {
801
904
  svg: traced.svg,
802
905
  w: w,
803
906
  h: h,
804
- keys: { imageAspect: 0 }, // 拉伸铺满:viewBox 就是原图像素系,节点框按它 1:1 画
907
+ keys: { imageAspect: 0 }, // 拉伸铺满:局部 viewBox 按节点框等比铺开
805
908
  }
806
909
  if (typeof op.id === 'string') synthetic.id = op.id
807
910
  if (typeof op.as === 'string') synthetic.as = op.as
@@ -809,15 +912,33 @@ async function applyOps(doc, ops, out, opctx) {
809
912
  if (typeof op.label === 'string') synthetic.label = op.label
810
913
  if (has(op, 'x')) synthetic.x = op.x
811
914
  if (has(op, 'y')) synthetic.y = op.y
915
+ // 裁剪块没给坐标时,落在原图里它该在的位置(否则会堆到原点压住整图)
916
+ if (traceCrop !== null && has(op, 'x') === false) synthetic.x = traceCrop.x
917
+ if (traceCrop !== null && has(op, 'y') === false) synthetic.y = traceCrop.y
812
918
  // 就地插一条 addNode 紧随其后(读像素是 async 的,所以这条 op 不能在解析参数时先展开)。
813
919
  // 注意:这里改的必须是**我们自己那份** ops —— 调用方(execute)已经 slice 过,
814
920
  // 因为宿主给的 arguments 是深冻结的,直接 splice 会抛 "object is not extensible"。
815
921
  ops.splice(i + 1, 0, synthetic)
922
+ // 节点 id:显式 id / as 别名会在下一条 addNode 里落地;这里先记 as 或 id 方便回执
923
+ const nodeRef = typeof op.id === 'string' ? op.id : typeof op.as === 'string' ? op.as : undefined
924
+ if (traceCrop !== null) metrics.crop = { x: traceCrop.x, y: traceCrop.y, w: traceCrop.w, h: traceCrop.h }
925
+ if (out !== undefined && out !== null) {
926
+ if (!Array.isArray(out.trace)) out.trace = []
927
+ out.trace.push(summarizeTraceForAi(metrics, { image: rawImage, nodeId: nodeRef }))
928
+ }
816
929
  notes.push(
817
- '🖼 ' + rawImage + ' → 逐像素矢量化:' + metrics.palette.length + ' 色 / ' + metrics.paths + ' 条 path / ' +
930
+ '🖼 ' + rawImage +
931
+ (traceCrop === null ? '' : ' 裁剪 ' + traceCrop.w + '×' + traceCrop.h + '@' + traceCrop.x + ',' + traceCrop.y) +
932
+ ' → 逐像素矢量化 preset=' + metrics.preset + ':' +
933
+ metrics.palette.length + ' 色 / ' + metrics.paths + ' 条 path / ' +
818
934
  metrics.loopsKept + ' 个轮廓环 / ' + metrics.svgChars + ' 字符' +
819
- '(工作图 ' + metrics.work.width + '×' + metrics.work.height + ',像素覆盖' +
820
- (metrics.exactPixelCoverage === true ? '精确' : '不精确 ' + metrics.drawnPixels + '/' + metrics.totalPixels) + ')',
935
+ '(工作图 ' + metrics.work.width + '×' + metrics.work.height +
936
+ ', scale ' + metrics.scaleApplied +
937
+ (metrics.sizeNotes ? '; ' + metrics.sizeNotes : '') +
938
+ ',像素覆盖' +
939
+ (metrics.exactPixelCoverage === true ? '精确' : '不精确 ' + metrics.drawnPixels + '/' + metrics.totalPixels) +
940
+ ')' +
941
+ (metrics.tuneHint ? '\n 调参:' + metrics.tuneHint : ''),
821
942
  )
822
943
  continue
823
944
  }
@@ -1848,7 +1969,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1848
1969
  {op:"setLayer", id|ids, layer}
1849
1970
  {op:"setLayerProps", layer, name?, visible?, locked?}
1850
1971
  {op:"export", format?:"svg"|"png", name?}
1851
- {op:"traceImage", image, w?, h?, x?, y?, colors?, grid?, scale?, epsilon?, minArea?, as?, layer?}
1972
+ {op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, cropX?, cropY?, cropW?, cropH?, as?, layer?}
1852
1973
  {op:"move", id|ids, dx?, dy?, x?, y?}
1853
1974
  {op:"remove", id|ids}
1854
1975
  {op:"highlight", ids:["n1","n2"]}
@@ -1874,17 +1995,25 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1874
1995
  pending → done(带文件路径)/ downloaded / failed。看到 pending 说明画布还没刷新 ——
1875
1996
  别反复请求,等下一次 read 再看(或提醒用户打开那张画布)。
1876
1997
  - **把一张位图"描"成矢量图用 traceImage**:{op:"traceImage", image:"ref.png", x:40, y:40}。
1877
- 它**真的读那张图的像素**(宿主里跑的管线:四边泛洪去背景 → k-means 定调色板 →
1878
- 3× 超采样归类 + 众数滤波 → 按颜色分连通域、沿像素格边取闭合轮廓 → RDP 简化 →
1879
- 每色一条 nonzero path,按面积降序铺),建成一个 svg 内容节点。
1998
+ 它**真的读那张图的像素**,建成一个 svg 内容节点。
1880
1999
  **不要用"手写一段近似的 svg"冒充复刻** —— 形状词汇表表达不了的照片/插画就直接用这个 op。
1881
- · 默认 **w/h = 原图像素尺寸**、viewBox = 原图像素坐标系,所以画布坐标就是图片坐标(1:1 铺满)。
1882
- · 要更简洁:colors 调小(默认 12)、scale:2 或 3(先降采样)、grid 调小(工作宽度)。
1883
- 要更保真:epsilon 调小(默认 2px)、minArea 调小(默认 10px²)。
1884
- · 输出会告诉你要点:色数 / 路径数 / 轮廓环数 / SVG 字符数 / **像素覆盖是否精确**
1885
- (精确 = 每色轮廓面积之和等于图像总像素,没有像素丢失或重复)。
1886
- · 单次上限:SVG 标记 60000 字符(超了会自己收紧 epsilon 与碎块阈值,仍超就报错让你调参)。
1887
- · 输入支持 PNG(自带解码器,零依赖)与 JPEG/GIF/WebP(需要宿主装了 sharp)。
2000
+ · 默认 **w/h = 原图像素尺寸**、viewBox = 原图像素坐标系(1:1 铺满)。
2001
+ · **先选 preset,再按需覆盖单字段**(合并:默认 ← preset ← 显式字段):
2002
+ - preset:"lineart" 插画/线稿(多暗色配额、严保细线)
2003
+ - preset:"photo" 照片/渐变
2004
+ - preset:"simple" 图标/简单图(少色+降采样,省字符)
2005
+ - 不写 = "default"
2006
+ · 调参闭环:看返回值里的 **trace[]**(结构化指标 + hint),不满意就 **remove 旧节点再 traceImage**(改 preset/字段)。
2007
+ 常见:字符紧 → scale:2 / 加大 epsilon / preset:"simple";线糊断 → preset:"lineart" 或提高 protectLuma/colorsDark。
2008
+ · 单次上限:SVG 标记 250000 字符(整图的精细度主要由它决定:预算紧时管线会抬 epsilon、丢小环,
2009
+ 把细节压掉——想要更细也可以给 maxBytes 顶到上限,或干脆用下面的裁剪只描局部);
2010
+ 输入 PNG(零依赖)与 JPEG/GIF/WebP(需 sharp)。
2011
+ · **五官/小物件糊成一团时,别只调参 —— 用 cropX/cropY/cropW/cropH 裁出那一块单独描**:
2012
+ 工作分辨率上限是 grid(默认 499px 宽),整张立绘塞进去时"脸"只占三四十像素,眼睛只有 3~4px,
2013
+ 量化后五官必然消失(实测把 colors 拉满 24、protectLuma 提到 235 也救不回来)。
2014
+ 裁出头部再描,同一块就占满工作图宽度 —— 脸能有一两百像素,五官才留得住。
2015
+ 裁剪块的坐标在**裁剪局部系**里,所以要按比例给它 w/h(= 整张图的 w/h × 裁剪块宽高 / 整块原尺寸),
2016
+ x/y 不写时落在原图对应位置,可与整图那块叠着放(顺序在后 = 画在上面)。
1888
2017
 
1889
2018
  - **改一组用 ids**:{op:"setStyle", ids:["n1","n2","n5"], style:"green"} —— setStyle / setLabel /
1890
2019
  move / remove 都收 ids,不要为了"把这几个改成绿色"发 12 个 op。批量 move 只收 dx/dy
@@ -1945,7 +2074,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1945
2074
  (那就把 w/h 设成图形的比例,否则四周会留白)。
1946
2075
  · svg 节点**可以没有 label**(画图不带字);要图下面带文字就照常给 label,它画在图上。
1947
2076
  · 颜色写在 SVG 自己身上(fill/stroke)—— 配色参数对它是无效的(图片不受 fillColor/strokeColor 影响)。
1948
- · 内容上限 60000 字符;**别放位图/base64**(那是图片不是图形,本画布画不出来)。
2077
+ · 内容上限 250000 字符;**别放位图/base64**(那是图片不是图形,本画布画不出来)。
1949
2078
  · 改内容只能整段替换(没有"改某一根线"的 op):先 read 拿回当前 svg,改完整段写回。
1950
2079
  - style:调色板名 plain|blue|green|orange|yellow|red|purple|grey,或直接给一段 style 串
1951
2080
  - dash:solid|dashed|dotted;arrow:end(单向)|both(双向)|none(无)|start(反向)
@@ -2768,7 +2897,7 @@ export function apply(ctx) {
2768
2897
  '{op:"setEdge", id, from?, to?, fromPoint?, toPoint?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
2769
2898
  '{op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?} / {op:"addLayer", name?, as?} / {op:"setLayer", id|ids, layer} / {op:"setLayerProps", layer, name?, visible?, locked?} / ' +
2770
2899
  '{op:"export", format?:"svg"|"png", name?} / ' +
2771
- '{op:"traceImage", image, w?, h?, x?, y?, colors?, grid?, scale?, epsilon?, minArea?, as?, layer?} / ' +
2900
+ '{op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, as?, layer?} / ' +
2772
2901
  '{op:"move", id|ids, dx?, dy?, x?, y?} / {op:"remove", id|ids} / {op:"highlight", ids:[...]};节点 id 省略时自动分配。' +
2773
2902
  '**改接一条边**用 setEdge(换 from/to 或某一端改成自由点)—— id、标签、标签位置、折点、端点约束全都留着;' +
2774
2903
  '不要 remove + addEdge 重画(那会把这些丢掉)。' +
@@ -2779,12 +2908,14 @@ export function apply(ctx) {
2779
2908
  '**图层**用 addLayer/setLayer/setLayerProps(加层、把单元移到别的层、改名/显示/隐藏/锁定;隐藏≠删除)。' +
2780
2909
  '**导出**用 export:format:"svg" 会让画布渲染一张 SVG 落在 .drawio 旁边、format:"png" 是浏览器下载;' +
2781
2910
  '这一步由画布执行,返回值里的 export.status 是 pending/done/downloaded/failed(pending = 画布还没刷新)。' +
2782
- '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y} 会真的去读那张图的像素' +
2783
- '(泛洪去背景 → k-means 定调色板 → 3× 超采样归类 → 按颜色取像素级闭合轮廓 → 每色一条 nonzero path),' +
2784
- '建成一个 svg 内容节点,**不是让模型凭印象重画**。默认 w/h = 原图像素尺寸、viewBox = 原图像素坐标系(1:1)。' +
2785
- '可调:colors(默认 12,少=更简洁)、grid(工作宽度,调小=更粗更快)、scale(先降采样几倍)、' +
2786
- 'epsilon(轮廓简化容差 px,默认 2,调大=更省字符)、minArea(小于这个面积的碎块并进最相似邻色)。' +
2787
- '返回的 summary 里带"色数/路径数/轮廓环/像素覆盖是否精确",据此判断质量。' +
2911
+ '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y} 会真的读像素建成 svg 节点,' +
2912
+ '**不是凭印象重画**。默认 w/h = 原图像素尺寸(1:1)。' +
2913
+ '**调参**:先选 preset(' + TRACE_PRESET_NAMES.join('|') + ';lineart=插画线稿、photo=照片、simple=省字符、default=默认),' +
2914
+ '再按需覆盖 colors/colorsDark/scale/epsilon/grid/gridH/minArea/minRegionArea/protectLuma/majorityContrast/bgColor 等。' +
2915
+ '合并顺序:默认 ← preset ← 显式字段。' +
2916
+ '返回值里的 **trace[]** 是结构化指标(svgChars/protectPixels/options/hint 等);' +
2917
+ '质量不够就根据 hint remove 旧节点再 trace(改 preset 或字段)。' +
2918
+ '字符紧→scale:2/加大epsilon/preset:simple;线糊断→preset:lineart 或提高 protectLuma/colorsDark。' +
2788
2919
  '**一次改一组**就写 ids:[...](setStyle/setLabel/move/remove 都收,move 的批量只收 dx/dy 相对位移)—— 用户说"把这几个换成绿色"时不要逐个发 op。' +
2789
2920
  '**引用刚建的那个**用 as:"名字":{op:"addNode", label:"开始", as:"start"} 之后同一个 ops 数组里 from/to/id 可以直接写 "start",不必猜自动分配的 n7。' +
2790
2921
  '**画图形(图标/logo/示意图)用 svg**:addNode {shape:"svg", svg:"<svg viewBox=…>…</svg>", w, h} 让节点的内容就是那段 SVG 标记' +
@@ -2876,6 +3007,64 @@ export function apply(ctx) {
2876
3007
  error: { type: 'string' },
2877
3008
  },
2878
3009
  },
3010
+ // 本次调用里每次 traceImage 一条:给模型读指标再调参(additionalProperties:false 必须声明)
3011
+ trace: {
3012
+ type: 'array',
3013
+ items: {
3014
+ type: 'object',
3015
+ additionalProperties: false,
3016
+ properties: {
3017
+ preset: { type: 'string' },
3018
+ image: { type: 'string' },
3019
+ nodeId: { type: 'string' },
3020
+ // 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)
3021
+ crop: {
3022
+ type: 'object',
3023
+ additionalProperties: false,
3024
+ properties: {
3025
+ x: { type: 'number' },
3026
+ y: { type: 'number' },
3027
+ w: { type: 'number' },
3028
+ h: { type: 'number' },
3029
+ },
3030
+ },
3031
+ source: {
3032
+ type: 'object',
3033
+ additionalProperties: false,
3034
+ properties: {
3035
+ width: { type: 'number' },
3036
+ height: { type: 'number' },
3037
+ },
3038
+ },
3039
+ work: {
3040
+ type: 'object',
3041
+ additionalProperties: false,
3042
+ properties: {
3043
+ width: { type: 'number' },
3044
+ height: { type: 'number' },
3045
+ scale: { type: 'number' },
3046
+ },
3047
+ },
3048
+ scaleApplied: { type: 'number' },
3049
+ sizeNotes: { type: 'string' },
3050
+ colors: { type: 'number' },
3051
+ colorsDark: { type: 'number' },
3052
+ colorsFill: { type: 'number' },
3053
+ protectPixels: { type: 'number' },
3054
+ majoritySkipped: { type: 'number' },
3055
+ paths: { type: 'number' },
3056
+ loopsKept: { type: 'number' },
3057
+ loopsProtectedKept: { type: 'number' },
3058
+ regionsProtected: { type: 'number' },
3059
+ svgChars: { type: 'number' },
3060
+ budget: { type: 'number' },
3061
+ attempts: { type: 'number' },
3062
+ exactPixelCoverage: { type: 'boolean' },
3063
+ options: { type: 'json' },
3064
+ hint: { type: 'string' },
3065
+ },
3066
+ },
3067
+ },
2879
3068
  },
2880
3069
  },
2881
3070
  render: function (args, value) {
@@ -2886,6 +3075,24 @@ export function apply(ctx) {
2886
3075
  }
2887
3076
  if (Array.isArray(value.changed) && value.changed.length > 0) bits.push('改动 ' + value.changed.join(', '))
2888
3077
  if (Array.isArray(value.removed) && value.removed.length > 0) bits.push('删除 ' + value.removed.join(', '))
3078
+ if (Array.isArray(value.trace) && value.trace.length > 0) {
3079
+ bits.push(
3080
+ '矢量化 ' +
3081
+ value.trace
3082
+ .map(function (t) {
3083
+ return (
3084
+ (t.image || '?') +
3085
+ ' preset=' +
3086
+ (t.preset || 'default') +
3087
+ ' ' +
3088
+ t.svgChars +
3089
+ '字' +
3090
+ (t.hint ? ' | ' + t.hint : '')
3091
+ )
3092
+ })
3093
+ .join(';'),
3094
+ )
3095
+ }
2889
3096
  return [
2890
3097
  {
2891
3098
  type: 'text',
@@ -3001,6 +3208,7 @@ export function apply(ctx) {
3001
3208
  created: [],
3002
3209
  changed: [],
3003
3210
  removed: [],
3211
+ ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3004
3212
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3005
3213
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3006
3214
  }
@@ -3052,6 +3260,7 @@ export function apply(ctx) {
3052
3260
  created: Array.isArray(out.created) ? out.created : [],
3053
3261
  changed: Array.isArray(out.changed) ? out.changed : [],
3054
3262
  removed: Array.isArray(out.removed) ? out.removed : [],
3263
+ ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3055
3264
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3056
3265
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3057
3266
  }