Claude로 PPT 자동화하다가 삽질한 것들, 스킬 파일이 만들어지기까지

2026. 6. 17. 12:21·Dev

시작: 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 좌표 추출 패턴: 한번 삽질해서 알아낸 것들을 다시 겪지 않아도 된다!

'Dev' 카테고리의 다른 글

API 명세서로 UI 만들기: Claude 단독 vs Gemini 협업 효율 비교 (feat. ccusage)  (0) 2026.02.06
클로드 코드는 왜 가끔 산으로 갈까?  (1) 2026.01.27
Claude Code 클로드 코드로 10분만에 나만의 모닝 뉴스레터 봇 만들기  (0) 2026.01.25
클로드코드 설치 했는데 "Command Not Found"?  (0) 2026.01.25
Git 에러: POST git-receive-pack (chunked), send-pack: unexpected disconnect while reading sideband packet, fatal: the remote end hung up unexpectedly, Everything up-to-date  (0) 2025.11.07
'Dev' 카테고리의 다른 글
  • API 명세서로 UI 만들기: Claude 단독 vs Gemini 협업 효율 비교 (feat. ccusage)
  • 클로드 코드는 왜 가끔 산으로 갈까?
  • Claude Code 클로드 코드로 10분만에 나만의 모닝 뉴스레터 봇 만들기
  • 클로드코드 설치 했는데 "Command Not Found"?
Lucy96
Lucy96
개발새발 프론트엔드 개발자
  • Lucy96
    Lucy dev ✨
    Lucy96
  • 전체
    오늘
    어제
    • 분류 전체보기 (31)
      • JavaScript (3)
      • React, Next.js (7)
      • HTTP (1)
      • GIS (2)
      • 회고 (3)
      • Dev (14)
      • CSS (1)
      • DB (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • github
  • 공지사항

  • 인기 글

  • 태그

    BEM
    geojson
    OAuth 2.0
    webapis
    JavaScript
    콜백큐
    Hoisting
    react
    CSS
    회고
    cliend id
    Google Cloud Platform
    Mapbox
    gcp
    블로킹
    cors
    토스모닥불
    프론트엔드
    Cookie
    oauth
    scope
    소셜로그인
    자바스크립트엔진
    sessionStorage
    localStorage
    scss
    이벤트루프
    HTTP
    토스
    논블로킹
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Lucy96
Claude로 PPT 자동화하다가 삽질한 것들, 스킬 파일이 만들어지기까지
상단으로

티스토리툴바