시작: PPT를 직접 만들면 왜 항상 틀어질까
Claude한테 PPT 만들어달라고 하면 결과물이 들쭉날쭉하다. 텍스트가 넘치거나, 요소가 겹치거나, 여백이 이상하거나.
처음엔 프롬프트 문제인 줄 알고 더 자세하게 써봤는데 달라지지 않았다. 근본적인 이유는 따로 있었다.
PPT는 시각적 피드백이 없다. Claude는 좌표 숫자를 쓸 뿐이고, 실제로 어떻게 보이는지는 PowerPoint를 직접 열어봐야만 안다. 열어보기 전까지는 모른다.
그래서 HTML을 중간 단계로 넣는 워크플로우를 만들었다.
Claude → HTML 생성 → 브라우저 미리보기 → 레이아웃 확정 → PPT 변환
HTML은 브라우저에서 바로 확인할 수 있고, 수정도 즉각 반영된다. 디자인을 HTML에서 완전히 잡고 나서 PPT로 변환하니까 열어보기 전까지 모르는 문제가 사라졌다.
변환하면서 터진 버그들
1. hex 색상에 # 넣으면 파일 자체가 깨짐
PptxGenJS는 색상값에 #을 넣으면 안 된다. 넣으면 파일이 아예 열리지 않는다.
fill: { color: "#1E2761" } // 파일 깨짐
fill: { color: "1E2761" } // 정상
shadow의 opacity도 8자리 hex로 쓰면 안 된다.
shadow: { color: "00000020" } // 파일 깨짐
shadow: { color: "000000", opacity: 0.12 } // 정상
2. shadow 객체 재사용하면 두 번째 카드부터 이상해짐
PptxGenJS가 shadow 객체를 in-place로 변환하기 때문에, 같은 객체를 여러 shape에 넘기면 첫 번째 이후로 다 깨진다.
// NG — 객체 재사용
const shadow = { type: "outer", blur: 6, color: "000000", opacity: 0.12 };
slide.addShape(..., { shadow });
slide.addShape(..., { shadow }); // 두 번째부터 깨짐
// OK — 매번 새 객체 생성
const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, angle: 135, color: "000000", opacity: 0.12 });
slide.addShape(..., { shadow: makeShadow() });
slide.addShape(..., { shadow: makeShadow() });
3. ROUNDED_RECTANGLE 위에 오버레이 올리면 모서리가 안 덮임
둥근 모서리 카드에 왼쪽 컬러 바를 올리려고 RECTANGLE을 위에 얹었더니, 둥근 모서리 부분이 튀어나왔다.
// NG
slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { x:1, y:1, w:3, h:1.5, rectRadius: 0.1 });
slide.addShape(pres.shapes.RECTANGLE, { x:1, y:1, w:0.08, h:1.5 }); // 모서리 삐져나옴
// OK — 둘 다 RECTANGLE로
slide.addShape(pres.shapes.RECTANGLE, { x:1, y:1, w:3, h:1.5 });
slide.addShape(pres.shapes.RECTANGLE, { x:1, y:1, w:0.08, h:1.5 });
4. bullet에 • 문자 쓰면 점이 두 개 생김
• 항목 이렇게 직접 넣으면 PptxGenJS 기본 bullet이랑 중복돼서 점이 두 개 나온다.
// NG — unicode 직접
{ text: "• 항목 1", options: { breakLine: true } }
// OK — bullet: true 사용
{ text: "항목 1", options: { bullet: true, breakLine: true } }
5. z-order — shape 위에 텍스트가 안 보임
addShape 다음에 addText를 불러도 텍스트가 안 보이는 경우가 있었다. 순서가 꼬이면 shape이 텍스트 위로 올라온다.
// 반드시 배경 shape 먼저, 텍스트 나중
slide.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: 1.0, w: 2.8, h: 1.8, fill: { color: "FFFFFF" } });
slide.addText("카드 제목", { x: 0.4, y: 1.1, w: 2.8, h: 0.4, ... });
그라데이션 이미지도 마찬가지다. 배경 rect → 그라데이션 이미지 → 텍스트 순서를 지켜야 텍스트가 가려지지 않는다.
좌표가 틀어지는 진짜 원인
HTML에서 position: absolute로 좌표를 직접 명시한 요소는 px / 80 공식으로 바로 변환된다.
PPT 인치 = HTML px / 80
(800px 너비 = 10인치, 450px 높이 = 5.625인치)
그런데 flex, grid, transform이 섞이면 얘기가 달라진다. CSS 값이 있어도 브라우저가 직접 계산해야 최종 픽셀이 나오는 속성들이 있기 때문이다.
CSS 패턴 문제
| display: flex / grid | 자식 위치는 컨테이너 크기 계산 후 결정 |
| transform: translateY(-50%) | 요소 자신의 높이를 알아야 계산 가능 |
| margin: auto, gap | 남은 공간을 동적으로 분배 |
| position: absolute 중첩 | 기준점이 가장 가까운 조상 — offset 누적 필요 |
이런 경우엔 수동으로 계산하거나, Puppeteer로 브라우저가 실제로 렌더한 좌표를 읽어오는 게 정확하다.
const puppeteer = require('puppeteer');
async function extractRects(htmlPath, selectors) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 800, height: 450 });
await page.goto('file://' + htmlPath);
const rects = await page.evaluate((sels) => {
return sels.reduce((acc, sel) => {
const el = document.querySelector(sel);
if (el) acc[sel] = el.getBoundingClientRect().toJSON();
return acc;
}, {});
}, selectors);
await browser.close();
return rects;
}
// getBoundingClientRect → PPT 인치 변환
const toPpt = (rect) => ({
x: rect.x / 80,
y: rect.y / 80,
w: rect.width / 80,
h: rect.height / 80,
});
// 슬라이드 여러 개가 세로로 쌓인 HTML이면
// 각 .slide 기준 상대 좌표로 보정해야 함
const slideRect = rects['.slide-1'];
const toPptRelative = (rect) => ({
x: (rect.x - slideRect.x) / 80,
y: (rect.y - slideRect.y) / 80,
w: rect.width / 80,
h: rect.height / 80,
});
단순한 슬라이드는 CSS 파싱으로 충분하고, flex/grid/transform이 섞이면 Puppeteer를 쓴다.
그라데이션은 PNG로 구워서 넣는다
PPT는 CSS linear-gradient를 지원하지 않는다. 그냥 무시된다.
해결책은 SVG로 그라데이션을 만들고 sharp으로 PNG 변환 후 addImage로 넣는 것이다.
const sharp = require('sharp');
async function makeGradientPng(wPx, hPx, colorHex, direction = 'btot') {
const r = parseInt(colorHex.slice(0, 2), 16);
const g = parseInt(colorHex.slice(2, 4), 16);
const b = parseInt(colorHex.slice(4, 6), 16);
const coords = {
btot: { x1: "0", y1: "1", x2: "0", y2: "0" }, // 아래→위
ttob: { x1: "0", y1: "0", x2: "0", y2: "1" }, // 위→아래
ltor: { x1: "0", y1: "0", x2: "1", y2: "0" }, // 좌→우
rtol: { x1: "1", y1: "0", x2: "0", y2: "0" }, // 우→좌
}[direction];
const svg = `<svg width="${wPx}" height="${hPx}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g" x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}">
<stop offset="0%" stop-color="rgb(${r},${g},${b})" stop-opacity="0.95"/>
<stop offset="55%" stop-color="rgb(${r},${g},${b})" stop-opacity="0.5"/>
<stop offset="100%" stop-color="rgb(${r},${g},${b})" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="${wPx}" height="${hPx}" fill="url(#g)"/>
</svg>`;
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return 'image/png;base64,' + png.toString('base64');
}
// 사용 예
const grad = await makeGradientPng(340, 265, '0F1C14', 'btot');
slide.addImage({ data: grad, x: 0, y: 2.3, w: 4.25, h: 3.325 });
QA도 자동화
PPT 열어서 눈으로 확인하는 것도 귀찮아서 LibreOffice CLI로 이미지로 뽑아서 확인하는 방식으로 바꿨다.
# PPT → PDF
soffice --headless --convert-to pdf output.pptx
# PDF → 슬라이드별 이미지
pdftoppm -jpeg -r 150 output.pdf slide
# → slide-1.jpg, slide-2.jpg ... 생성
이걸 Claude 코드 실행 환경에서 돌리면 슬라이드 이미지를 바로 볼 수 있어서 수정→재확인 사이클이 훨씬 빨라진다.
확인 체크리스트:
- 텍스트 잘림 (h 부족)
- 요소 겹침 (z-order 역전)
- 가장자리 여백 0.5인치 미만
- 밝은 배경에 밝은 글씨 (저대비)
스킬 파일로 만든 이유
이 과정에서 발견한 버그 패턴들을 매번 프롬프트에 설명하기가 번거로워서 스킬 파일로 정리했다. proposal-ppt 스킬을 만들어두면 Claude가 PPT 변환할 때 자동으로 이 규칙들을 따른다.
hex에 # 금지, shadow 객체 재사용 금지, z-order 순서, 그라데이션 처리 방법, Puppeteer 좌표 추출 패턴: 한번 삽질해서 알아낸 것들을 다시 겪지 않아도 된다!