Guía de Resolución de Problemas de Penpot MCP
Propósito
Este documento recopila lecciones aprendidas construyendo el Sistema de Diseño AstroVille en Penpot a través del servidor MCP. El objetivo es evitar redescubrir estas peculiaridades de Penpot en proyectos futuros. Muchos de estos comportamientos no están documentados y solo aparecen en tiempo de ejecución al exportar o agrupar elementos.
La lección más crítica: siempre envuelve el contenido de una sección en un Group, luego posiciona todo dentro del Grupo usando coordenadas simples (x, y) relativas al origen del grupo. Esto reduce drásticamente los errores de posicionamiento.
TL;DR — La Regla de "Agrupa Primero"
Para cada sección de alto nivel de un diseño de Penpot (por ejemplo, un tablero que representa "Colores", "Tipografía", "Espaciado"):
- Crea el tablero.
- Envuelve todos sus hijos en un Group (Penpot lo crea automáticamente cuando llamas a
penpot.group([...children])). - Posiciona el Group en la esquina superior izquierda deseada de tu área de contenido, por ejemplo
penpotUtils.setParentXY(group, 64, 40). - Dentro del Group, todas las posiciones de los hijos usan coordenadas simples basadas en
(0, 0)relativas al origen del grupo. - Posiciona siempre los hijos PRIMERO en coordenadas relativas a
(0, 0), LUEGO agrúpalos, no al revés.
Este patrón es lo que hace que el posicionamiento sea predecible y que la exportación funcione correctamente.
Problema 1 — Los hijos de una tarjeta obtienen parentX / parentY negativos después de agrupar
Síntoma
Después de llamar a penpot.group([...children]) para envolver el contenido de una sección, los hijos anidados dentro de las tarjetas terminan con valores parentX / parentY negativos. Por ejemplo, un Rectángulo que debería estar en (0, 0) relativo a su tarjeta termina en (-64, -210) — dibujado fuera de los límites de la tarjeta.
Visualmente, los fondos de las tarjetas (los mosaicos de color) aparecen vacíos porque el Rectángulo de relleno real está posicionado fuera.
Causa raíz
Cuando agrupas formas que ya estaban colocadas en posiciones absolutas en un tablero padre, Penpot calcula el cuadro delimitador del grupo. El grupo mismo se posiciona en (minX, minY) de sus miembros. Luego, para cada hijo, su parentX / parentY se recalcula relativo al nuevo origen del grupo.
Si la posición absoluta de un hijo era, digamos, (64, 218) y el grupo termina en (64, 218), el parentX/parentY del hijo se convierte en (0, 0). Pero si la tarjeta que contiene al hijo estaba en (64, 218) y el grupo termina en, digamos, (64, 40), entonces todo dentro de la tarjeta obtiene un parentY negativo porque la tarjeta está "más arriba" que el origen del grupo.
Solución
Posiciona siempre a los hijos en coordenadas relativas al origen eventual del grupo (0, 0) ANTES de llamar a penpot.group(). Después de agrupar, el origen del grupo se convierte en (0, 0) para sus hijos.
Si ya agrupaste y los hijos están desalineados, la recuperación es:
penpot.ungroup(group)— desagrupa de vuelta.- Resta el desplazamiento del grupo previsto de la posición
y(ox) de cada hijo. - Vuelve a llamar
penpot.group(children). penpotUtils.setParentXY(group, desiredParentX, desiredParentY).
Concretamente (el patrón que funcionó):
// 1. Guarda las posiciones absolutas originales que deseas preservar.
const originalPositions = children.map(c => ({ c, x: c.x, y: c.y }));
// 2. Desplaza cada hijo por -groupOffset (para que sean relativas al origen del grupo).
for (const child of children) {
child.y -= GROUP_OFFSET_Y; // p.ej. 40
}
// 3. Agrúpalos.
const group = penpot.group(children);
// 4. Posiciona el grupo mismo.
if (group) {
penpotUtils.setParentXY(group, group.parentX, GROUP_OFFSET_Y);
}
Problema 2 — Las formas de texto con lineHeight fijo (valor en píxeles) se renderizan con una altura enorme
Síntoma
Una forma de texto con fontSize: 48 y lineHeight: 56 (un valor en píxeles) termina con bounds.height: 2689 (o un valor absurdo similar). El texto se ajusta verticalmente — el texto renderizado real aparece mucho más abajo de donde lo colocaste (a menudo en y: 1313 en términos absolutos), por lo que el texto es invisible.
Causa raíz
El lineHeight de Penpot acepta una cadena multiplicadora numérica como "1.2", pero rechaza algunos valores como "56" o cualquier valor no numérico que no sea un multiplicador. Cuando se pasa un valor no válido, Penpot usa silenciosamente una altura de línea predeterminada que puede ser enorme, causando ajuste de línea. La exportación SVG confirma el error: el atributo y del elemento <text> informa un valor enorme como 1386.44.
Además, incluso cuando un lineHeight en píxeles aparentemente válido es aceptado, el cálculo de los límites para texto de auto-width puede producir valores extraños como width: 338 pero height: 2689, porque el motor de texto ajusta dentro de los límites que el texto cree que tiene en el momento de la creación.
Solución
Usa siempre lineHeight: "1.2" (un multiplicador de cadena) en lugar de valores en píxeles. La forma de texto tendrá entonces límites correctos y predecibles:
const t = penpot.createText("ASTROGENESIS");
parent.appendChild(t);
t.resize(800, 80); // redimensiona ANTES de establecer fontSize
t.x = 64;
t.y = 100;
t.fontFamily = "Space Grotesk";
t.fontWeight = "700";
t.fontSize = 48;
t.lineHeight = "1.2"; // ← usa "1.2", no "56"
t.fills = [{ fillColor: "#F8FAFC", fillOpacity: 1 }];
Esto funciona tanto si fontSize se establece antes o después de anexar, siempre que resize() se llame ANTES de fontSize y lineHeight sea una cadena multiplicadora.
Problema 3 — El cambio de fontSize después de anexar da dimensiones 1×1 si no se llama a resize() primero
Síntoma
Un elemento de texto creado con penpot.createText(), anexado a un padre, y luego se le asigna fontSize: 32 termina con width: 1, height: 1 inicialmente. Después de un retraso, los límices se recuperan, pero el texto puede renderizarse en posiciones inesperadas.
Causa raíz
Penpot mide las dimensiones del texto ansiosamente usando los límites actuales. Si el texto se crea y tiene dimensiones 1×1 en el momento en que el motor de fuentes mide, lo trata como restringido a 1 píxel de ancho y ajusta agresivamente.
Solución
Siempre resize(width, height) ANTES de establecer fontSize. Esto le da al texto un cuadro delimitador conocido. El ayudante más simple:
function makeText(parent, chars, opts) {
const t = penpot.createText(chars);
parent.appendChild(t);
t.resize(opts.w || 400, opts.h || 60); // redimensiona primero
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"; // multiplicador, NO píxeles
t.fills = [{ fillColor: opts.color || "#F8FAFC", fillOpacity: 1 }];
if (opts.transform === "uppercase") t.textTransform = "uppercase";
return t;
}
Problema 4 — El espaciado entre letras con unidades em es rechazado
Síntoma
Establecer text.letterSpacing = "0.08em" falla con Value not valid: 0.08em. Code: :letterSpacing.
Solución
El letterSpacing de Penpot solo acepta valores numéricos (interpretados como 1/100 em, o similar), no cadenas con sufijo em. Usa "1" (o 0, "2", etc.) en lugar de "0.08em". Para un control más preciso usa un número pequeño como "1" o "2".
Problema 5 — penpot.generateMarkup() devuelve contenido que la exportación PNG no renderiza
Síntoma
Construyes formas, el marcado SVG las muestra correctamente (<rect>, <text>, paradas de gradiente <stop>, etc.), pero export_shape devuelve un PNG en blanco o uno al que le falta el contenido esperado. No hay mensajes de error.
Causa raíz
Dos causas principales observadas:
- Tableros anidados con
clipContent: truey dimensiones pequeñas pueden recortar el área visible. Penpot exporta usando el cuadro delimitador, pero el contenido interno vive más allá de él. - Envoltorios de grupo con contenido desplazado (Problema 1 anterior) hacen que la exportación de Penpot use textBounds obsoletos. El marcado SVG puede mostrar los elementos, pero la exportación no respeta los desplazamientos.
Solución
- Confía en el marcado SVG (
penpot.generateMarkup) como verdad fundamental cuando algo no se renderiza. - Después de solucionar el Problema 1 (hijos en posiciones relativas correctas), las exportaciones funcionan correctamente.
- Verifica exportando primero tableros individuales pequeños para aislar cuál está fallando.
Problema 6 — Los hijos de un tablero de sección desaparecen después de agrupar
Síntoma
Después de agrupar, los mosaicos/tarjetas/etiquetas de la sección desaparecen de la exportación visible, pero aún existen en la estructura de datos. Los contenedores de las tarjetas aparecen (sus bordes, rellenos) pero su contenido (Rectángulo, hijos de Texto) se dibuja fuera de la tarjeta.
Causa raíz
Esto es lo mismo que el Problema 1: los hijos de las tarjetas tienen parentX / parentY negativos y se dibujan fuera de los límites de su tarjeta.
Solución
Después de agrupar, recorre las tarjetas y restablece explícitamente la posición de cada hijo anidado a su ubicación prevista relativa a su tarjeta. Por ejemplo, en una tarjeta de color con un Rectángulo de muestra en (0, 0) y tres etiquetas de texto en (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); // Rectángulo en la parte superior izquierda de la tarjeta
penpotUtils.setParentXY(ch[1], 16, 132); // etiqueta
penpotUtils.setParentXY(ch[2], 16, 154); // nombre del token
penpotUtils.setParentXY(ch[3], 16, 174); // valor hexadecimal
}
Problema 7 — La raíz de la página (page.root) tiene dimensiones fijas diminutas, por lo que los tableros anchos pueden romper las exportaciones
Síntoma
El tablero raíz de la página de Penpot es siempre 0.01 × 0.01 en tamaño — sus dimensiones no se pueden redimensionar. Cualquier cosa que le agregues vive en coordenadas absolutas fuera del cuadro delimitador de la raíz.
Causa raíz
Penpot trata la raíz de la página como un marcador de posición; todo el contenido real vive en tableros creados por el usuario colocados en posiciones absolutas en la página.
Solución
Esto está bien si mantienes las cosas en tableros con nombre con sus propias coordenadas absolutas (como haces normalmente). Simplemente no intentes usar page.root.resize(). Si necesitas un contenedor maestro, coloca los tableros hijos directamente en page.root en lugar de anidarlos en un tablero maestro.
Problema 8 — Sombras: color debe ser { color: "#hex", opacity: 0..1 }, no { r, g, b, a }
Síntoma
Establecer una sombra como:
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
}];
Se renderiza como negro con baja opacidad en lugar de violeta. La propiedad .shadows de la forma se lee de vuelta como:
{ color: { color: "#000000", opacity: 0.2 }, ... }
Causa raíz
Shadow.color es un objeto Color de Penpot con la forma { color: "#hex", opacity: 0..1 } — no un objeto {r, g, b, a}. La forma incorrecta es ignorada silenciosamente y Penpot recurre a una sombra paralela negra predeterminada.
Solución
Usa la forma de objeto Color con una cadena hexadecimal y un campo opacity separado:
shape.shadows = [{
color: { color: "#8B5CF6", opacity: 0.25 },
offsetX: 0, offsetY: 0, blur: 24, spread: 0,
style: "drop-shadow", visible: true
}];
O usa penpot.shapesColors([shape]) para obtener una instancia de Color e inspeccionar sus campos antes de aplicar.
Problema 9 — Las coordenadas startX / startY / endX / endY del gradiente están normalizadas, no en píxeles
Síntoma
Establecer un gradiente con coordenadas de píxeles:
shape.fills = [{
fillColorGradient: {
type: "linear",
startX: 0, startY: 0, endX: 320, endY: 160,
stops: [...]
},
fillOpacity: 1
}];
Se lee de vuelta con endX: ~1.0, endY: ~1.0 — Penpot normalizó silenciosamente el vector. El campo width mantiene la escala original, pero el vector de dirección ahora está en el espacio [0, 1].
Causa raíz
Penpot almacena la dirección del gradiente como un vector normalizado de 0 a 1. Los valores en píxeles se dividen por width. Si el vector resultante no estaba estrictamente en [0, 1] (por ejemplo, endX: 320 / 320 = 1.0 exactamente, o endY: 160 / 320 = 0.5), Penpot parece recortar y redondear.
En AstroVille, el usuario arrastró el segundo punto de color en la interfaz de usuario de Penpot para colocarlo en la esquina inferior derecha del rectángulo. Los valores posteriores fueron endX: ~1.0, endY: ~1.0. El posicionamiento de gradiente perfecto en píxeles no se expone como una propiedad escribible: la interfaz de usuario es la fuente de verdad para las posiciones de los controles del gradiente.
Solución
- Para un gradiente horizontal (izquierda → derecha):
endX: 1, endY: 0 - Para diagonal (superior izquierda → inferior derecha):
endX: 1, endY: 1 - Para vertical (superior → inferior):
endX: 0, endY: 1 - Para posiciones de parada precisas del gradiente a lo largo de la dirección, usa
offset(0–1) por parada. - Si las posiciones exactas de los controles del gradiente importan, hazlas en la interfaz de usuario de Penpot y lee de vuelta para confirmar.
Patrón Recomendado: La Plantilla de Página "Foundations" de AstroVille
La estructura que funcionó para la página "00 — Design System" de AstroVille:
page.root
├── "00 — Design System / Header" (tablero, 0, 0, 1440×120)
│ └── Group en (64, 40)
│ ├── "Design System" texto en (0, 0) → se renderiza en (64, 40)
│ └── "AstroVille — Foundations" texto en (0, 52) → (64, 92)
│
├── "01 — Colors" (tablero, 0, 200, 1440×1460)
│ └── Group en (64, 40)
│ ├── Textos de Título + Descripción en (0, 0), (0, 48)
│ ├── Etiqueta "Background" en (0, 100)
│ ├── 3 Tarjetas de Color en una fila en (0, 178), (264, 178), (528, 178)
│ │ Cada tarjeta tiene sus propios hijos en (0, 0), (16, 132), (16, 154), (16, 174)
│ └── ... 3 grupos más (Brand, Feedback, Text) en (0, 498), (0, 818), (0, 1138)
│
└── ... otras secciones con la misma estructura
Desplazamientos y de sección (relativos al (0, 0) del grupo):
| Tipo de sección | Diseño |
|---|---|
| Encabezado | Título (0, 0), Subtítulo (0, 52) |
| Bloque de título | Título (0, 0), Descripción (0, 48) |
| Encabezado de grupo | Etiqueta (0, 100), Descripción (0, 130) |
| Fila de tarjetas | 3 tarjetas en (0, 178), (264, 178), (528, 178) |
| Siguiente fila | 320px debajo de la anterior: (0, 498), (0, 818), (0, 1138) |
Ayudante verificado para crear tableros y textos:
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;
}
Resumen de Causas Raíz
| Problema | Causa raíz | Solución |
|---|---|---|
| 1. Hijos con parentX/Y negativos | El grupo cambia el origen, los hijos mantienen posiciones absolutas antiguas | Posiciona los hijos en (0,0) relativos antes de agrupar |
| 2. Altura de texto enorme | lineHeight en píxeles rechazado, se usa predeterminado enorme | Usa "1.2" (cadena multiplicadora) |
| 3. Texto 1×1 | fontSize evaluado antes de que el texto tenga límites | Llama a resize() antes de fontSize |
| 4. El espaciado de letras falla | em no es un formato válido para letterSpacing | Usa números como "1" |
| 5. PNG en blanco | Recorte del tablero anidado o desplazamiento del grupo | Confía en SVG; corrige el Problema 1; exporta tableros pequeños primero |
| 6. Las tarjetas pierden contenido | Hijos con parentX/Y negativos (mismo que el Problema 1) | Restablece explícitamente las posiciones de los hijos de las tarjetas |
| 7. Raíz de página diminuta | page.root es fijo 0.01×0.01 | Usa tableros con nombre; no redimensiones la raíz |
| 8. Sombra negra | Formato de color incorrecto (r,g,b,a) | Usa { color: "#hex", opacity: 0..1 } |
| 9. Gradiente incorrecto | Coordenadas en píxeles normalizadas a 0–1 | Usa endX: 1, endY: 1 para guardar |
Notas de la Traducción
Esta guía fue traducida para ayudar a los desarrolladores que trabajan con Penpot en entornos de habla hispana. Los nombres de funciones (como penpot.group, penpotUtils.setParentXY) y las variables de código se han dejado en su forma original en inglés para mantener la compatibilidad con la API de Penpot.