Penpot MCP 문제 해결 가이드
목적
이 문서는 MCP 서버를 통해 Penpot에서 AstroVille 디자인 시스템을 구축하면서 얻은 귀중한 교훈을 기록합니다. 목표는 향후 프로젝트에서 이러한 Penpot의 까다로운 동작을 다시 발견하지 않는 것입니다. 이러한 동작 중 상당수는 문서화되어 있지 않으며, 요소를 내보내거나 그룹화할 때 런타임에서만 나타납니다.
가장 중요한 교훈: 항상 섹션 콘텐츠를 그룹(Group)으로 감싼 다음, 그룹 내부에서 모든 요소를 그룹 원점을 기준으로 간단한 (x, y) 좌표를 사용하여 배치하십시오. 이렇게 하면 위치 지정 오류가 크게 줄어듭니다.
요약 — "그룹 우선" 규칙
Penpot 디자인의 각 최상위 섹션(예: "색상", "타이포그래피", "간격"을 나타내는 보드)에 대해 다음 단계를 따르십시오.
- 보드를 생성합니다.
- 모든 자식 요소를 그룹으로 감쌉니다. (Penpot은
penpot.group([...children])을 호출할 때 자동으로 생성합니다.) - 그룹 자체를 콘텐츠 영역의 원하는 왼쪽 상단에 배치합니다. 예:
penpotUtils.setParentXY(group, 64, 40). - 그룹 내부에서 모든 자식 위치는 그룹 원점을 기준으로 간단한
(0, 0)기반 좌표를 사용합니다. - 항상 자식 요소를 먼저
(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)이 됩니다.
이미 그룹화했고 자식이 잘못 정렬된 경우 복구 방법은 다음과 같습니다.
penpot.ungroup(group)— 평면화로 되돌립니다.- 모든 자식의
y(또는x) 위치에서 의도한 그룹 오프셋을 뺍니다. penpot.group(children)을 다시 호출합니다.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"; // ← "56" 대신 "1.2" 사용
t.fills = [{ fillColor: "#F8FAFC", fillOpacity: 1 }];
이것은 resize()가 fontSize 전에 호출되고 lineHeight가 승수 문자열인 한 fontSize가 추가 전후에 설정되든 관계없이 작동합니다.
문제 3 — 추가 후 fontSize 변경 시 먼저 resize()를 호출하지 않으면 1×1 크기가 됨
증상
penpot.createText()로 생성된 텍스트 요소를 상위 요소에 추가하고 fontSize: 32를 설정하면 처음에 width: 1, height: 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 단위의 자간(letter-spacing)이 거부됨
증상
text.letterSpacing = "0.08em"을 설정하면 Value not valid: 0.08em. Code: :letterSpacing 오류가 발생합니다.
해결 방법
Penpot의 letterSpacing은 숫자 값(1/100 em 또는 유사하게 해석됨)만 허용하며 em 접미사 문자열은 허용하지 않습니다. "0.08em" 대신 "1"(또는 0, "2" 등)을 사용하십시오. 더 미세한 제어를 위해 "1" 또는 "2"와 같은 작은 숫자를 사용하십시오.
문제 5 — penpot.generateMarkup()이 PNG 내보내기에서 렌더링되지 않는 콘텐츠를 반환함
증상
도형을 만들고 SVG 마크업은 올바르게 표시되지만(<rect>, <text>, 그라디언트 <stop> 등) export_shape는 빈 PNG 또는 예상 콘텐츠가 누락된 PNG를 반환합니다. 오류 메시지가 없습니다.
근본 원인
두 가지 주요 원인이 관찰되었습니다.
clipContent: true및 작은 크기의 중첩 보드는 표시 영역을 자를 수 있습니다. Penpot은 경계 상자를 사용하여 내보내지만 내부 콘텐츠는 그 너머에 있습니다.- 콘텐츠가 이동된 그룹 래퍼(위 문제 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); // 토큰 이름
penpotUtils.setParentXY(ch[3], 16, 174); // 16진수 값
}
문제 7 — 페이지 루트(page.root)의 크기가 작게 고정되어 넓은 보드가 내보내기를 손상시킬 수 있음
증상
Penpot 페이지 루트 보드의 크기는 항상 0.01 × 0.01 입니다. 크기를 조정할 수 없습니다. 여기에 추가하는 모든 것은 루트의 경계 상자 외부의 절대 좌표에 있습니다.
근본 원인
Penpot은 페이지 루트를 플레이스홀더로 취급합니다. 모든 실제 콘텐츠는 페이지의 절대 위치에 배치된 사용자 생성 보드에 있습니다.
해결 방법
일반적인 방식처럼 모든 것을 고유한 절대 좌표가 있는 명명된 보드에 유지하면 문제가 없습니다. page.root.resize()를 사용하려고 하지 마십시오. 마스터 컨테이너가 필요한 경우 마스터 보드에 중첩하는 대신 자식 보드를 page.root에 직접 배치하십시오.
문제 8 — 그림자: color는 { r, g, b, a }가 아닌 { color: "#hex", opacity: 0..1 } 형식이어야 함
증상
다음과 같이 그림자를 설정합니다.
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는 { color: "#hex", opacity: 0..1 } 형식의 Penpot Color 객체입니다. {r, g, b, a} 객체가 아닙니다. 잘못된 형식은 자동으로 무시되고 Penpot은 기본 검정색 드롭 섀도우로 대체됩니다.
해결 방법
16진수 문자열과 별도의 opacity 필드가 있는 Color 객체 형식을 사용하십시오.
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개 카드 at (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;
}