Communitygithub.com

l4ur4oliveira/astroville

该技能记录了通过 MCP 服务器在 Penpot 中构建 AstroVille 设计系统时遇到的硬核经验教训,旨在避免在后续项目中重新发现这些 Penpot 的隐藏问题。

astroville 是什么?

该技能是一个全面的 Penpot MCP 问题解决指南,专为通过 MCP 服务器在 Penpot 中构建设计系统的开发者设计。它总结了构建 AstroVille 设计系统期间发现的关键经验教训,重点解决 Penpot 中未文档化或仅在运行时出现的常见陷阱,例如分组后子元素位置错误、文本形状高度异常、字体大小设置后尺寸异常、字母间距单位被拒绝、渐变坐标归一化以及导出内容空白等问题。该技能支持 Penpot 平台,并提供了详细的问题描述、根本原因分析和经过验证的修复方案。其核心建议是“先分组”规则:始终将内容包裹在组内,然后使用相对于组原点的简单 (x, y) 坐标进行定位,这能显著减少定位错误,确保设计系统导出的可靠性和可预测性。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/l4ur4oliveira/astroville/tree/HEAD/.agents/skills/penpot-problem-solving

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

Penpot MCP 问题解决指南

目的

本文档记录了通过 MCP 服务器在 Penpot 中构建 AstroVille 设计系统时获得的硬核经验教训。目标是避免在未来的项目中重新发现这些 Penpot 怪癖。许多这些行为在文档中并未记载,只会在元素导出或分组时在运行时显现。

最重要的教训是:始终将部分内容包裹在一个组 (Group) 中,然后使用相对于组原点的简单 (x, y) 坐标来定位组内的所有内容。这可以显著减少定位错误。


TL;DR — “先分组”规则

对于 Penpot 设计中每个顶级部分(例如,代表“颜色”、“排版”、“间距”的面板):

  1. 创建面板。
  2. 将其所有子元素包裹在一个组中(当你调用 penpot.group([...children]) 时,Penpot 会自动创建组)。
  3. 将组本身定位在所需内容区域的左上角,例如 penpotUtils.setParentXY(group, 64, 40)。
  4. 在组内部,所有子元素的位置都使用相对于组原点的简单 (0, 0) 基础坐标。
  5. 始终先将子元素定位在 (0, 0) 相对坐标,然后将它们分组,而不是反过来。

这个模式是使定位可预测且导出正常工作的方法。


问题 1 — 卡片内的子元素在分组后得到负的 parentX / parentY

症状

调用 penpot.group([...children]) 来包裹一个部分的内容后,卡片内嵌套的子元素最终会得到负的 parentX / parentY 值。例如,一个本应位于卡片内 (0, 0) 的矩形最终位于 (-64, -210) —— 被绘制到了卡片边界之外。

从视觉上看,卡片背景(彩色色板)似乎为空,因为实际的填充矩形被定位在了外部。

根本原因

当你对已经以绝对位置放置在父面板上的形状进行分组时,Penpot 会计算组的边界框。组本身被定位在其成员的 (minX, minY)。然后,对于每个子元素,其 parentX / parentY 将相对于组的新原点重新计算。

如果某个子元素的绝对位置是 (64, 218),而组最终位于 (64, 218),那么该子元素的 parentX/parentY 将变为 (0, 0)。但是,如果包含该子元素的卡片位于 (64, 218),而组最终位于(例如)(64, 40),那么卡片内的所有内容都会得到负的 parentY,因为卡片“高于”组的原点。

修复

始终在调用 penpot.group() 之前,将子元素定位在相对于最终组原点 (0, 0) 的坐标。分组后,组的原点对于其子元素来说变为 (0, 0)。

如果你已经分组且子元素未对齐,恢复的方法是:

  1. penpot.ungroup(group) —— 展平回来。
  2. 从每个子元素的 y(或 x)位置中减去预期的组偏移量。
  3. 再次执行 penpot.group(children)。
  4. 执行 penpotUtils.setParentXY(group, desiredParentX, desiredParentY)。

具体来说(行之有效的模式):

// 1. 保存你想要保留的原始绝对位置。
const originalPositions = children.map(c => ({ c, x: c.x, y: c.y }));

// 2. 将每个子元素移动 -groupOffset(使它们相对于组原点)。
for (const child of children) {
  child.y -= GROUP_OFFSET_Y;   // 例如 40
}

// 3. 将它们分组。
const group = penpot.group(children);

// 4. 定位组本身。
if (group) {
  penpotUtils.setParentXY(group, group.parentX, GROUP_OFFSET_Y);
}

问题 2 — 具有固定 lineHeight(像素值)的文本形状渲染出巨大的高度

症状

一个 fontSize: 48 和 lineHeight: 56(像素值)的文本形状最终 bounds.height 为 2689(或类似荒谬的值)。文本会垂直换行——实际渲染的文本出现在你放置位置的下方(绝对 y 值常常为 1313),因此文本不可见。

根本原因

Penpot 的 lineHeight 接受像 "1.2" 这样的数字乘数字符串,但拒绝某些值,如 "56" 或任何非数字、非乘数的值。当传入无效值时,Penpot 会静默使用一个可能巨大的默认行高,导致换行。SVG 导出证实了这个错误——<text> 元素的 y 属性报告了一个巨大的值,如 1386.44。

此外,即使一个看似有效的像素 lineHeight 被接受,auto-width 文本的边界计算也可能产生奇怪的值,比如 width: 338 但 height: 2689,因为文本引擎在它认为文本创建时拥有的边界内进行换行。

修复

始终使用 lineHeight: "1.2"(一个字符串乘数),而不是像素值。这样,文本形状将具有正确、可预测的边界:

const t = penpot.createText("ASTROGENESIS");
parent.appendChild(t);
t.resize(800, 80);                  // 在设置 fontSize 之前先调整大小
t.x = 64;
t.y = 100;
t.fontFamily = "Space Grotesk";
t.fontWeight = "700";
t.fontSize = 48;
t.lineHeight = "1.2";               // ← 使用 "1.2",而不是 "56"
t.fills = [{ fillColor: "#F8FAFC", fillOpacity: 1 }];

无论 fontSize 是在追加之前还是之后设置,这都有效,只要 resize() 在 fontSize 之前调用,并且 lineHeight 是一个乘数字符串。


问题 3 — 追加后更改 fontSize 如果不先调用 resize(),则会导致 1×1 的尺寸

症状

使用 penpot.createText() 创建、追加到父元素并设置 fontSize: 32 的文本元素,其初始 width 和 height 为 1、1。一段时间后,边界会恢复,但文本可能会在意外位置渲染。

根本原因

Penpot 急切地使用当前边界来测量文本尺寸。如果在字体引擎测量时文本被创建并具有 1×1 的尺寸,它就会将文本视为被约束为 1 像素宽并积极换行。

修复

始终在设置 fontSize 之前 resize(width, height)。这为文本提供了一个已知的边界框。最简单的辅助函数:

function makeText(parent, chars, opts) {
  const t = penpot.createText(chars);
  parent.appendChild(t);
  t.resize(opts.w || 400, opts.h || 60);  // 先调整大小
  t.x = opts.x;
  t.y = opts.y;
  t.fontFamily = opts.fontFamily || "Inter Tight";
  t.fontWeight = opts.fontWeight || "400";
  t.fontSize = opts.fontSize || 14;
  t.lineHeight = opts.lineHeight || "1.2";  // 乘数,不是像素
  t.fills = [{ fillColor: opts.color || "#F8FAFC", fillOpacity: 1 }];
  if (opts.transform === "uppercase") t.textTransform = "uppercase";
  return t;
}

问题 4 — 带有 em 单位的字母间距被拒绝

症状

设置 text.letterSpacing = "0.08em" 失败,并显示 Value not valid: 0.08em. Code: :letterSpacing。

修复

Penpot 的 letterSpacing 只接受数值(解释为 1/100 em,或类似值),不接受 em 后缀的字符串。使用 "1"(或 0、"2" 等)而不是 "0.08em"。为了更精细的控制,使用像 "1" 或 "2" 这样的小数字。


问题 5 — penpot.generateMarkup() 返回的内容 PNG 导出没有渲染

症状

你构建了形状,SVG 标记正确显示了它们(<rect>、<text>、渐变 <stop> 等),但 export_shape 返回了一个空白 PNG 或一个缺少预期内容的 PNG。没有错误消息。

根本原因

观察到两个主要原因:

  1. 嵌套面板启用了 clipContent: true 且尺寸过小可能会裁剪可见区域。Penpot 使用边界框导出,但内部内容超出了它。
  2. 包含偏移内容的组包装器(上面的问题 1)导致 Penpot 的导出使用过时的 textBounds。SVG 标记可能会显示这些元素,但导出不会处理这些偏移。

修复

  • 当某些内容无法渲染时,相信 SVG 标记(penpot.generateMarkup)作为事实来源。
  • 修复问题 1(子元素位于正确的相对位置)后,导出可以正常工作。
  • 通过首先导出小的单独面板来验证,以隔离哪个面板失败。

问题 6 — 部分面板的子元素在分组后消失

症状

分组后,该部分的色板/卡片/标签从可见导出中消失,但它们仍然存在于数据结构中。卡片容器出现(它们的边框、填充),但它们的内容(矩形、文本子元素)被绘制在卡片外。

根本原因

这与问题 1 相同——卡片的子元素具有负的 parentX / parentY,并被绘制到卡片边界之外。

修复

分组后,遍历卡片并显式地将每个嵌套子元素的位置重置为其相对于卡片的预期位置。例如,在一个带有位于 (0, 0) 的矩形色板和三个位于 (16, 132)、(16, 154)、(16, 174) 的文本标签的彩色卡片中:

const cards = group.children.filter(c => c.name && c.name.startsWith("Color Card"));
for (const card of cards) {
  const ch = card.children;
  if (ch.length < 4) continue;
  penpotUtils.setParentXY(ch[0], 0,   0);    // 矩形位于卡片左上角
  penpotUtils.setParentXY(ch[1], 16,  132);  // 标签
  penpotUtils.setParentXY(ch[2], 16,  154);  // token 名称
  penpotUtils.setParentXY(ch[3], 16,  174);  // 十六进制值
}

问题 7 — 页面根节点 (page.root) 具有固定的微小尺寸,因此宽面板可能会破坏导出

症状

Penpot 页面根面板始终是 0.01 × 0.01 大小——其尺寸无法调整大小。你添加到其上的任何内容都位于根节点边界框之外的绝对坐标中。

根本原因

Penpot 将页面根节点视为占位符;所有真实内容都位于用户创建的面板中,这些面板放置在页面上的绝对位置。

修复

如果你将内容放在命名面板中(通常是这样),并具有它们自己的绝对坐标,这没问题。只是不要尝试使用 page.root.resize()。如果你需要一个主容器,请将子面板直接放在 page.root 上,而不是将它们嵌套在主面板中。


问题 8 — 阴影:color 必须是 { color: "#hex", opacity: 0..1 },而不是 { r, g, b, a }

症状

像下面这样设置阴影:

shape.shadows = [{
  color: { r: 139/255, g: 92/255, b: 246/255, a: 0.25 },
  offsetX: 0, offsetY: 0, blur: 24, spread: 0,
  style: "drop-shadow", visible: true
}];

渲染为低透明度的黑色,而不是紫色。形状的 .shadows 读取回来后是:

{ color: { color: "#000000", opacity: 0.2 }, ... }

根本原因

Shadow.color 是一个 Penpot Color 对象,形状为 { color: "#hex", opacity: 0..1 } —— 不是 {r, g, b, a} 对象。错误的形状会被静默忽略,Penpot 会回退到默认的黑色投影。

修复

使用 Color 对象形状,其中包含一个十六进制字符串和一个单独的 opacity 字段:

shape.shadows = [{
  color: { color: "#8B5CF6", opacity: 0.25 },
  offsetX: 0, offsetY: 0, blur: 24, spread: 0,
  style: "drop-shadow", visible: true
}];

或者使用 penpot.shapesColors([shape]) 获取一个 Color 实例并在应用前检查其字段。


问题 9 — 渐变 startX / startY / endX / endY 是归一化的,而不是像素

症状

使用像素坐标设置渐变:

shape.fills = [{
  fillColorGradient: {
    type: "linear",
    startX: 0, startY: 0, endX: 320, endY: 160,
    stops: [...]
  },
  fillOpacity: 1
}];

读取回来后 endX: ~1.0,endY: ~1.0 —— Penpot 静默地归一化了向量。width 字段保留了原始比例,但方向向量现在位于 [0, 1] 空间中。

根本原因

Penpot 将渐变方向存储为归一化的 0–1 向量。像素值会除以 width。如果结果向量不严格在 [0, 1] 范围内(例如 endX: 320 / 320 = 1.0 或 endY: 160 / 320 = 0.5),Penpot 会出现裁剪和舍入。

在 AstroVille 中,用户在 Penpot UI 中拖动了第二个颜色点,将其放置到矩形的右下角。之后的值是 endX: ~1.0, endY: ~1.0。像素完美的渐变定位不作为可写属性暴露——UI 是渐变手柄位置的事实来源。

修复

  • 对于水平渐变(从左到右):endX: 1, endY: 0
  • 对于对角线(从左上到右下):endX: 1, endY: 1
  • 对于垂直(从上到下):endX: 0, endY: 1
  • 对于沿渐变方向的精确渐变停止位置,使用每个停止点的 offset(0–1)。
  • 如果确切的渐变手柄位置很重要,请在 Penpot UI 中执行并读取回来以确认。

推荐模式:AstroVille “基础”页面模板

适用于 AstroVille 的 "00 — Design System" 页面的结构:

page.root
├── "00 — Design System / Header" (board, 0, 0, 1440×120)
│   └── Group at (64, 40)
│       ├── "Design System"  text at (0, 0)   → renders at (64, 40)
│       └── "AstroVille — Foundations" text at (0, 52) → (64, 92)
│
├── "01 — Colors" (board, 0, 200, 1440×1460)
│   └── Group at (64, 40)
│       ├── Title + Description texts at (0, 0), (0, 48)
│       ├── "Background" label at (0, 100)
│       ├── 3 Color Cards in a row at (0, 178), (264, 178), (528, 178)
│       │   Each card has its own children at (0, 0), (16, 132), (16, 154), (16, 174)
│       └── ... 3 more groups (Brand, Feedback, Text) at (0, 498), (0, 818), (0, 1138)
│
└── ... other sections with the same structure

部分 y 偏移(相对于组的 (0, 0)):

部分类型布局
页眉标题 (0, 0), 副标题 (0, 52)
标题块标题 (0, 0), 描述 (0, 48)
组标题标签 (0, 100), 描述 (0, 130)
卡片行3 张卡片位于 (0, 178), (264, 178), (528, 178)
下一行距上一行 320px: (0, 498), (0, 818), (0, 1138)

已验证可用的创建面板和文本的辅助函数:

function makeText(parent, chars, opts) {
  const t = penpot.createText(chars);
  parent.appendChild(t);
  t.resize(opts.w || 400, opts.h || 60);
  t.x = opts.x;
  t.y = opts.y;
  t.fontFamily = opts.fontFamily || "Inter Tight";
  t.fontWeight = opts.fontWeight || "400";
  t.fontSize = opts.fontSize || 14;
  t.lineHeight = opts.lineHeight || "1.2";
  t.fills = [{ fillColor: opts.color || "#F8FAFC", fillOpacity: 1 }];
  if (opts.transform === "uppercase") t.textTransform = "uppercase";
  return t;
}

相关技能