본문 바로가기
MegazoneCloud
← 전체 글
Azure

Extreme Copilot Studio: 스킬 만으로 PDF 파일 만들어서 내보내기

지난 10월 6일부터 3일간 열린 AI FESTA 2026에서, 메가존클라우드 Microsoft AI 전시 부스가 선보인 에이전트에는 PDF 파일을 만드는 기능이 들어 있었습니다. 이 기능은 이 글에서 설명하는 content-to-pdf 스킬을 기반으로 구현했고, 별도의 SaaS 서비스는 쓰지 않았습니다. 보통이라면 외부 서비스를 붙여서 해결해야 할 일을 스킬 하나로 끝까지 밀어붙여 본 것입니다.

Copilot Studio 에이전트 스킬은 인바운드와 아웃바운드 연결이 모두 막힌 격리 런타임에서 실행됩니다. 그래서 content-to-pdf 스킬은 reportlab 같은 외부 패키지 없이, Python 표준 라이브러리만으로 PDF 파일을 바이트 단위로 직접 조립합니다. 이 글은 그 과정에서 마주친 제약과 해법을 정리한 기록입니다.

외부 서비스 대신 스킬을 택한 이유

부스 에이전트는 방문객과 대화하며 요구 사항을 정리하는 AI 요구 사항 조사를 담당하는 에이전트로 기획했고, 방문객과의 대화가 끝나면 정리한 내용을 PDF로 건네려는 의도를 구현하려 했습니다. Copilot Studio에서 이런 기능을 만드는 일반적인 방법은 커넥터나 에이전트 흐름으로 외부 변환 서비스를 호출하는 것입니다. 이 방법을 택하지 않은 데에는 세 가지 이유가 있었습니다.

  • 비용과 계약 부담. 변환 서비스에는 대개 별도 구독이나 사용량 과금이 붙습니다. 사흘짜리 행사를 위해 새 계약을 맺고 연결을 설정하는 일은 결과물에 비해 준비가 큽니다.
  • 데이터가 지나는 경로. 방문객이 입력한 내용이 테넌트 밖 서비스를 거칩니다. 같은 구성을 고객사에 제안하면 기능 검토보다 보안 검토가 먼저 시작됩니다.
  • 스킬로 어디까지 할 수 있는지에 대한 질문. 스킬은 지침과 함께 스크립트를 묶어 배포할 수 있습니다. 검증한 코드를 패키지에 넣어 두면 에이전트는 코드를 새로 생성하지 않고 그 코드를 실행하므로, 같은 입력에 같은 결과가 나옵니다. 이 방식이 격리 런타임 안에서 실무 수준의 작업까지 감당할 수 있는지 직접 확인하고 싶었습니다.

한글 PDF 생성은 세 번째 질문을 시험하기에 알맞은 과제였습니다. 바이너리 형식, 폰트, 문자 인코딩을 한꺼번에 다뤄야 해서 샌드박스의 제약이 빠짐없이 드러나기 때문입니다. 여기서 길이 열리면 다른 결정론적 작업도 같은 방식으로 스킬에 담을 수 있다고 판단했습니다.

스킬이 실행되는 곳: Microsoft 하네스의 격리 런타임

Copilot Studio의 스킬은 Microsoft 하네스 (구 GitHub Copilot 하네스)1로 동작하는 에이전트가 쓰는 기능입니다(Skills overview). 스킬 하나는 SKILL.md(프런트매터의 이름과 설명, 본문 지침)와 스크립트, 리소스 파일을 묶은 폴더입니다. 오케스트레이터는 사용자 요청이 스킬 설명과 맞을 때만 그 지침을 컨텍스트로 불러옵니다.

스크립트가 실행되는 샌드박스를 직접 확인해 보니 특징은 다음과 같았습니다.

  • 인바운드와 아웃바운드 연결이 모두 막혀 있습니다. Microsoft 365 Copilot의 스킬 문서도 스크립트의 네트워크 접근과 런타임 패키지 설치를 지원하지 않는다고 명시합니다(Custom skills in declarative agents).
  • 진입점은 Python입니다. 에이전트가 셸에서 python scripts/content_to_pdf.py <입력> -o <출력 파일 경로.pdf>를 실행합니다. 셸을 쓸 수 없으면 모듈을 import해서 convert()를 바로 호출해도 됩니다.
  • 런타임 안에 Node.js도 있습니다. Python 스크립트가 필요할 때 Node를 하위 프로세스로 호출할 수 있었습니다. 허용 스크립트 형식에 .js, .mjs, .cjs, .ts, .mts가 들어 있는 것과도 맞아떨어집니다.
  • 커넥터, API 플러그인, MCP 서버는 오케스트레이터 쪽 기능입니다. 샌드박스 안의 스크립트가 직접 부를 수는 없습니다.

결국 스크립트가 기댈 수 있는 것은 런타임에 미리 깔린 Python과 Node, 그리고 스킬 패키지에 직접 넣은 파일뿐입니다. content-to-pdf는 Node 없이 Python만 쓰지만, JavaScript 생태계의 순수 JS 라이브러리를 패키지에 동봉해 Node로 돌리는 설계도 같은 샌드박스에서 가능하다는 뜻입니다.

Copilot Studio 스킬 실행 환경: 오케스트레이터와 격리 샌드박스

커넥터와 MCP 서버는 오케스트레이터 쪽에 있고, 샌드박스 안의 스크립트는 외부와 통하지 않은 채 패키지 파일과 런타임만 씁니다.

설계 원칙: 표준 라이브러리만 쓰는 단일 Python 파일

변환 엔진은 scripts/content_to_pdf.py 한 파일(약 3,800줄)이고, Python 표준 라이브러리 이외의 외부 구성 요소나 별도 pip 패키지는 일절 사용하지 않았습니다. 가령 reportlab이나 fpdf는 pip 설치가 필요하고, 샌드박스에 미리 깔린 패키지도 문서는 “가용성이 확인되지 않았다면 의존하지 말라”고 안내하기 때문입니다.

표준 모듈이 스크립트에서 맡은 일
structTrueType 테이블, PNG 청크, JPEG 마커, EXIF 파싱
zlibPDF 스트림 압축(FlateDecode), PNG 해제, 번들 폰트 해제
zipfile + xml.etreeDOCX, PPTX, XLSX(OOXML) 파싱, ZIP으로 묶은 정적 사이트 입력
html.parserHTML을 문단, 표, 목록 블록으로 변환
base64 + hashlib텍스트로 번들한 폰트 복원과 SHA-256 검증
unicodedataNFC 정규화, 제어 문자와 제로폭 문자 제거

에이전트 쪽 운영 규칙은 SKILL.md에 두 줄로 고정했습니다.

  • PDF 생성 코드를 새로 쓰지 말고 항상 번들 스크립트를 실행한다.
  • pip install을 시도하지 않는다.

모델이 매번 코드를 생성하면 결과가 매번 달라집니다. 검증된 스크립트를 실행하게 하면 같은 입력에 같은 PDF가 나옵니다. 스크립트는 성공하면 출력 경로, 쪽수, 사용 폰트, warnings를 JSON으로 출력하고, 실패하면 {"error": ...}와 종료 코드 1`을 돌려줍니다. 에이전트는 이 JSON만 읽고 사용자에게 결과와 경고를 풀어서 전합니다.

PDF를 직접 쓰기: 객체 목록과 xref 테이블

PDF는 번호가 붙은 객체들과, 각 객체가 파일의 몇 번째 바이트에서 시작하는지 적은 xref 테이블로 이루어집니다. 이 구조만 지키면 라이브러리 없이도 뷰어가 열 수 있는 파일이 됩니다. 스크립트의 PdfWriter는 이 일을 약 35줄로 처리합니다.

  1. 카탈로그, 페이지 트리, 각 페이지의 객체 번호를 reserve()로 먼저 받아 둡니다. 내용을 다 만든 뒤 set()으로 채우므로, 앞쪽 객체가 아직 없는 뒤쪽 객체를 참조할 수 있습니다.
  2. 내용 스트림과 폰트 파일은 zlib.compress(data, 6)으로 압축해 /FlateDecode 스트림으로 넣습니다.
  3. %PDF-1.7 헤더 뒤에 객체를 차례로 붙이면서 바이트 오프셋을 기록하고, 마지막에 xref 테이블과 trailer를 씁니다.
def write(self, root, info):
    out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
    offsets = []
    for i in range(1, len(self.objs)):
        offsets.append(len(out))
        out += b"%d 0 obj\n" % i + self.objs[i] + b"\nendobj\n"
    xref = len(out)
    out += b"xref\n0 %d\n0000000000 65535 f \n" % len(self.objs)
    for off in offsets:
        out += b"%010d 00000 n \n" % off
    # trailer: /Root, /Info, /ID 뒤에 startxref와 %%EOF

이 위에 문서다운 기능을 올렸습니다.

  • 책갈피: Markdown과 HTML의 제목 수준을 스택으로 계층화해 /Outlines 트리를 만들고, /PageMode /UseOutlines로 책갈피 패널이 열린 채 시작하게 합니다.
  • 링크: 외부 URL은 /URI 동작을 가진 링크 주석으로, # 제목 같은 문서 내부 링크는 해당 페이지 좌표로 가는 /Dest로 넣습니다.
  • 문서 언어: 카탈로그에 /Lang (ko-KR)을 적어 화면 낭독기가 한국어로 읽게 합니다.
  • 메타데이터: 제목과 책갈피 문자열은 UTF-16BE로 쓰고 앞에 FE FF를 붙입니다. PDF 규격이 유니코드 텍스트 문자열에 BOM을 요구하기 때문입니다. 입력에서는 BOM을 모두 지우는 스크립트가 출력에서는 BOM을 직접 붙이는 셈입니다.

한글 출력의 핵심: TrueType 서브셋과 Identity-H

PDF 표준 폰트(Helvetica, Courier 등)에는 한글이 없습니다. 어느 뷰어에서나 한글이 같게 보이려면 TrueType 폰트를 PDF에 내장해야 하고, 2 MB짜리 폰트를 통째로 넣지 않으려면 문서에 쓴 글자만 남기는 서브셋이 필요합니다. 표준 라이브러리에는 폰트 도구가 없으므로 이 부분도 struct로 직접 구현했습니다.

TrueType 읽기

  • 테이블 디렉터리에서 head, hhea, maxp, hmtx, cmap, loca, glyf를 찾습니다. TTC는 file.ttc#1처럼 번호로 고릅니다.
  • cmap은 포맷 4(BMP)와 포맷 12(BMP 밖 문자)를 읽어 코드포인트 → 글리프 ID 표를 만듭니다.
  • glyf 윤곽이 아닌 CFF 기반 폰트(.otf, Noto Sans CJK .ttc)는 받지 않습니다.
  • OS/2 fsType이 0x0002(내장 제한)이면 내장하지 않고, 0x0100(서브셋 금지)이면 모든 글리프를 남깁니다.

글리프 ID를 바꾸지 않는 서브셋

글리프 개수와 번호는 그대로 두고, 쓰지 않는 글리프의 윤곽만 비웁니다. loca 항목은 남고 glyf 데이터만 0바이트가 됩니다.

  • 복합 글리프가 참조하는 부품 글리프는 따라가서 함께 남깁니다.
  • GSUB, GPOS, kern, vmtx처럼 PDF 렌더링에 필요 없는 테이블은 버리고, post는 글리프 이름이 없는 포맷 3으로 줄입니다.
  • 번호를 다시 매기지 않으므로 cmap과 hmtx를 고칠 필요가 없고, PDF에서는 /CIDToGIDMap /Identity 한 줄로 CID와 글리프 ID가 같아집니다.

PDF 쪽 구성

  • Type0 폰트 + /Encoding /Identity-H + CIDFontType2 자손 폰트 + /FontFile2 스트림으로 엮습니다. 본문 문자열은 글리프 ID를 2바이트 16진수(<0a1b> Tj)로 씁니다.
  • /W 배열에는 실제로 쓴 글리프의 폭만 넣고, 폰트 이름 앞에는 ABCDEF+ 형태의 서브셋 태그를 붙입니다.
  • ToUnicode CMap에 글리프 ID → 유니코드 대응을 적습니다. 이것이 없으면 화면에는 한글이 보여도 복사하거나 검색하면 깨집니다.

샌드박스와 같은 조건(시스템 폰트 없음)에서 제목, 서식, 표가 들어간 짧은 한글 문서를 변환하면, 원본 폰트는 2,054,744바이트지만 결과 PDF는 1쪽 37,206바이트였습니다.

1 MB 패키지에 한글 폰트 넣기

샌드박스에는 한글 폰트가 없으므로 폰트도 스킬에 넣어야 했고, 여기서 두 가지 제약에 부딪혔습니다.

  • 허용 파일 형식에 .ttf가 없습니다. 문서에 나열된 형식은 문서, 데이터, 이미지와 스크립트뿐입니다.
  • 스킬 ZIP이 1 MB를 넘으면 에이전트가 제대로 초기화되지 않았습니다. 문서상 Agent Builder의 ZIP 한도는 50 MB이지만, Copilot Studio에서 직접 올려 보며 확인한 실제 한계는 훨씬 낮았습니다.

해법은 폰트를 zlib으로 압축한 뒤 base64 텍스트(NanumGothic-Regular.ttf.zb64.txt)로 바꾸는 것이었습니다. .txt는 허용되는 파일 형식입니다.

단계크기(바이트)
원본 NanumGothic-Regular.ttf2,054,744
zlib + base64 텍스트925,010
스킬 ZIP 안에서 다시 압축된 크기691,753
스킬 ZIP 전체(스크립트, 지침, 라이선스 포함)744,351

base64는 원본 대비 콘텐츠 크기를 33% 늘리지만, ZIP의 deflate가 그중 대부분을 다시 압축해줍니다. base64 문자는 64종류(글자당 6비트 정보)뿐이라 허프만 부호화가 다시 원래의 zlib 크기 근처로 줄여 주기 때문입니다.

불러오기와 검증

  • assets/fonts.json에 파일 경로, 원본 .ttf의 SHA-256, 라이선스, 출처를 적어 둡니다.
  • 스크립트는 폰트가 처음 필요한 글자가 나올 때 한 번만 base64 디코딩 → zlib 해제를 합니다. --font로 지정한 폰트가 모든 글자를 담고 있으면 번들은 풀지도 않습니다.
  • 복원한 바이트의 SHA-256이 매니페스트와 다르면 그 폰트를 쓰지 않습니다. 이 검증은 “원본을 수정하지 않고 재배포한다”는 OFL 1.1 조건의 근거도 됩니다.

서체 한 가지로 모든 스타일 만들기

처음에는 나눔고딕 Bold, 나눔명조, Pretendard, Noto Sans KR, D2Coding도 번들했지만 1 MB 안에 들어가지 않아 나눔고딕 Regular만 남겼습니다. 나머지 스타일은 PDF 연산자로 합성합니다.

  • 굵게: 텍스트 렌더링 모드 2 Tr(채우기 + 획)로 글자 크기의 3.5% 두께 윤곽선을 더합니다.
  • 기울임: 텍스트 행렬 1 0 0.2 1 x y Tm으로 약 11.3° 기울입니다.
  • 코드: 영문은 PDF 표준 폰트 Courier(내장 불필요, 폭 600 고정)로, 코드 안의 한글은 나눔고딕으로 그립니다.

글자별 폰트 대체 순서

나눔고딕에는 한글 11,172자와 영문, 숫자, 기본 기호만 있습니다. 그래서 글자 하나하나를 아래 순서로 찾습니다.

  1. --font 옵션 또는 CONTENT_TO_PDF_FONT 환경 변수로 지정한 폰트
  2. 번들한 나눔고딕 Regular
  3. 실행 환경의 TrueType 폰트(번들에 없는 글자가 있을 때만 찾고, 탐색은 10초로 제한)
  4. PDF 표준 CJK 폰트(HYGoThic-Medium + UniKS-UCS2-H 등). 내장하지 않고 뷰어에 맡기며 warnings로 알립니다.
  5. 어느 쪽에도 없는 글자(가령 이모지 등)는 ?로 바꾸고 경고합니다.

BOM 원칙과 인코딩 결정 순서

이 스킬에서 가장 엄격하게 지키는 규칙은 “모든 텍스트 입력은 BOM (Byte Order Mark)을 먼저 제거한 뒤 다룬다”입니다. 한국어 Windows 환경에서 만든 파일에는 UTF-8 BOM(EF BB BF)이 흔하고, BOM이 남으면 문제가 세 군데에서 생깁니다.

  • SKILL.md 앞의 BOM은 YAML 프런트매터를 가려서, 스킬 로더가 이름과 설명을 읽지 못합니다.
  • CSV 첫 머리글 이름 앞에 보이지 않는 U+FEFF가 붙습니다.
  • %PDF, OLE 시그니처(구형 Office), {\rtf 같은 매직 넘버 판별이 어긋납니다. 그래서 형식 판별도 BOM을 뗀 뒤의 내용으로 합니다.

BOM은 긴 것부터 검사해야 합니다. UTF-32 LE BOM(FF FE 00 00)의 앞 2바이트가 UTF-16 LE BOM(FF FE)과 같기 때문입니다.

_BOMS = (
    (b"\x00\x00\xfe\xff", "utf-32-be"),
    (b"\xff\xfe\x00\x00", "utf-32-le"),  # UTF-16 LE보다 먼저
    (b"\xef\xbb\xbf", "utf-8"),
    (b"\xfe\xff", "utf-16-be"),
    (b"\xff\xfe", "utf-16-le"),
)

BOM을 뗀 뒤에는 아래 순서로 인코딩을 정합니다.

  1. BOM이 있었으면 BOM이 가리키는 인코딩. --encoding과 다르면 BOM을 따르고 경고를 남깁니다.
  2. --encoding으로 지정한 인코딩
  3. BOM 없는 UTF-16 추정: 홀수 자리 바이트의 30% 이상이 NUL이고 짝수 자리는 2% 미만이면 UTF-16 LE(반대면 BE)
  4. UTF-8
  5. HTML <meta charset>이나 XML encoding에 선언된 인코딩. EUC-KR은 상위 집합인 CP949로 바꿔 읽습니다.
  6. CP949
  7. CP1252

디코딩한 문자열도 한 번 더 정리합니다. 이중 BOM처럼 남은 선행 U+FEFF, 본문 중간의 U+FEFF, 제로폭 문자, 소프트 하이픈을 지우고, NFC로 정규화합니다. NFC는 한글에서 특히 중요합니다. macOS에서 온 NFD 텍스트는 자모가 분리돼 있어서, 그대로 그리면 음절이 아닌 낱자모로 보입니다.

에이전트가 붙여 넣은 텍스트를 파일로 저장할 때는 BOM 없는 UTF-8로 쓰고 utf-8-sig는 쓰지 않도록 SKILL.md에 적어 두었습니다.

입력 형식별 파서: 모든 길은 블록 목록으로

입력 형식은 12가지지만 렌더러는 하나입니다. 모든 파서는 제목, 문단, 표, 코드, 이미지 블록 목록(("h", level, text), ("table", rows, ...) 등)을 돌려주고, Renderer가 이를 줄 바꿈하고 쪽을 나눠 배치한 뒤 PdfWriter가 파일로 씁니다.

변환 파이프라인: 입력 → 블록 → Renderer → PdfWriter

Markdown만 한 단계를 더 거치고, 나머지는 모두 같은 블록 목록에서 만납니다.

  • Markdown은 HTML을 거칩니다. 자체 CommonMark/GFM 파서가 Markdown을 HTML 조각으로 바꾸고, HTML 파서가 그 결과를 블록으로 만듭니다. 그래서 Markdown 안에 섞인 <details>, <br>, <table>을 따로 처리할 필요가 없습니다.
  • HTML은 본문만 고릅니다. HTMLParser를 상속한 파서가 <main> → role="main" → 하나뿐인 <article> 순으로 본문 영역을 찾고, <nav>, 사이트 머리글/바닥글, 숨김 요소, 스크립트, 폼 컨트롤을 뺍니다. 고른 영역이 비어 있으면 페이지 전체로 다시 변환합니다.
  • Office 문서는 ZIP 속 XML입니다. DOCX, PPTX, XLSX를 zipfile로 열고 xml.etree로 읽습니다. 형식은 확장자가 아니라 ZIP 안의 word/document.xml, ppt/presentation.xml, xl/workbook.xml로 판별합니다. Word의 한국어 번호 형식(ganada 가.나.다., chosung ㄱ.ㄴ.ㄷ.)도 재현하고, XLSX 수식은 계산하지 않고 저장된 결과값을 씁니다.
  • ZIP으로 묶은 정적 사이트는 안전하게 풉니다. .., 절대 경로, 드라이브 문자가 든 항목은 건너뛰고(Zip Slip 방지), 푼 크기가 500 MB를 넘으면 멈춥니다. 대표 페이지는 index.html → README.md 또는 index.md 순으로 고릅니다.
  • ZIP 안의 한글 파일 이름을 살립니다. UTF-8 플래그가 없는 항목 이름은 zipfile이 CP437로 읽어 버립니다. 그래서 CP437 바이트로 되돌린 뒤 UTF-8 → CP949 순으로 다시 디코딩합니다. 한국어 Windows 압축 도구가 만든 ZIP이 이 경우입니다.
  • 이미지는 가능하면 풀지 않고 넣습니다. JPEG은 마커만 훑어 크기, 색 공간, EXIF 회전 값을 읽고 데이터는 그대로 /DCTDecode로 넣습니다. 알파 없는 PNG는 IDAT 압축 데이터를 /FlateDecode + /Predictor 15로 그대로 넘기므로, PNG 행 필터는 PDF 뷰어가 풀어 줍니다. 알파 채널이 있는 PNG만 직접 필터를 풀어 알파를 /SMask로 분리합니다.
  • 원격 이미지는 요청하지 않습니다. https:// 이미지는 대체 텍스트 [설명]으로 표시하고 경고를 남깁니다. 어차피 네트워크가 없으니 시도해서 타임아웃을 기다리는 것보다 낫습니다.

패키징 제약을 빌드 단계에서 막기: build.py

스킬 ZIP을 올린 뒤에야 실패를 알게 되면 원인을 찾기 어렵습니다. 그래서 알려진 제약을 build.py(약 80줄)가 모두 검사하고, 하나라도 어기면 빌드를 실패시킵니다.

제약근거build.py가 하는 일
ZIP 1,000,000바이트 이하직접 확인(문서상 한도는 50 MB)넘으면 ZIP을 지우고 실패. 한도가 없는 환경용으로만 –max-bytes 0
SKILL.md 지침 20,000자 미만공식 문서글자 수를 세서 넘으면 실패(현재 9,769자)
프런트매터가 파일 맨 앞스킬 로더---로 시작하는지, name: content-to-pdf가 있는지 확인
텍스트 파일에 BOM 없음BOM이 프런트매터를 가림담는 모든 파일에서 BOM을 제거하고 UTF-8로 기록
허용 파일 형식공식 문서.md, .py, .txt, .json만 담고 __pycache__, 점 (.)으로 이름이 시작하는 폴더 제외
디렉터리 깊이 3 이하공식 문서깊이를 세서 넘으면 실패
ZIP 루트에 SKILL.md업로드 형식스킬 폴더의 내용을 ZIP 루트에 배치

결과 패키지는 파일 5개, 744,351바이트입니다. Copilot Studio에서는 에이전트의 Build 탭 → Skills → Add skill → Upload a skill로 올리고, Microsoft 365 Copilot Agent Builder에서도 같은 ZIP을 쓸 수 있습니다.

테스트와 CI/CD: 샌드박스를 로컬에서 재현하기

샌드박스에서 디버깅하기는 어렵기 때문에, 샌드박스의 조건을 로컬과 CI에서 그대로 만드는 데 집중했습니다.

테스트

  • 시스템 폰트 없는 환경: CONTENT_TO_PDF_NO_SYSTEM_FONTS=1을 주면 스크립트가 OS 폰트 폴더를 보지 않고 번들 폰트만 씁니다.
  • 패키지 단독 실행: build.py로 만든 ZIP이 1,000,000바이트 이하인지, 파일 5개가 정확한지, SKILL.md가 BOM 없이 시작하는지 확인합니다. 이어서 그 ZIP만 임시 폴더에 풀고, 시스템 폰트 없이 별도 프로세스로 실행해 한글, 굵게, 코드가 경고 없이 변환되는지 봅니다.
  • PDF 내부 검증: PyMuPDF(fitz)가 있으면 PDF에서 텍스트를 다시 추출해 원문과 비교합니다. ToUnicode CMap이 맞는지도 이 단계에서 드러납니다. 없으면 xref와 쪽수 같은 구조만 확인합니다. PyMuPDF는 테스트 전용이고 스킬에는 들어가지 않습니다.
  • 저수준 검사: 서브셋 폰트를 다시 읽었을 때 글리프 수와 cmap이 원본과 같은지, 모든 FlateDecode 스트림이 압축 해제되는지, BOM 5종과 BOM 없는 UTF-16 텍스트와 EUC-KR HTML이 제대로 읽히는지 확인합니다.

GitHub Actions

main 브랜치 푸시, 풀 리퀘스트, 수동 실행, v* 태그 푸시에서 돌아갑니다.

  1. build 작업: Python 3.12에서 테스트를 돌리고 build.py로 ZIP을 만든 뒤 actions/upload-artifact에 archive: false로 올립니다. 산출물이 ZIP 안의 ZIP으로 다시 묶이지 않아서, 내려받은 파일을 바로 Copilot Studio에 올릴 수 있습니다.
  2. release 작업(태그일 때만): 태그가 SKILL.md의 metadata.version과 다르면 실패합니다. 같으면 GitHub Release를 만들고 ZIP을 첨부합니다.
  3. 손으로 만든 Release에 이미 content-to-pdf.zip이 있으면 덮어쓰지 않고 남겨 둡니다.

이렇게 만든 Copilot Studio용 Skill을 아래 그림과 같이 에이전트에 등록할 수 있습니다.

그리고 Copilot Studio에 내장된 스킬 생성 기능과 달리, zip 패키지의 원형을 있는 그대로 (as-is) 채택하는 것도 확인할 수 있습니다.

마치며: 한계와 다른 스킬에 가져갈 점

이 프로젝트의 결론은 “격리 런타임에서 필요한 것은 패키지에 다 넣고, 패키지 제약은 빌드에서 검사한다”입니다.

알려진 한계는 다음과 같습니다. 목표가 원본 레이아웃 재현이 아니라 내용을 빠짐없이 읽기 좋은 PDF로 만드는 것이기 때문입니다.

  • Office 문서의 이미지, 글꼴 색과 크기, 셀 병합과 HTML의 CSS 레이아웃은 옮기지 않습니다.
  • 굵게와 기울임은 Regular에서 만든 합성 스타일입니다.
  • 원격 이미지, GIF, SVG, WebP, 인터레이스 PNG, CFF 기반 OpenType 폰트는 지원하지 않습니다.
  • 쓸 수 있는 폰트에 없는 이모지는 ?로 바뀝니다.

Copilot Studio 스킬을 만드는 분이라면 다음을 참고하시면 좋겠습니다.

  • 네트워크와 pip 사용이 가능할 것이라는 가정을 두지 마세요. 표준 라이브러리로 쓰거나 필요한 코드를 패키지에 동봉합니다. Python에서 Node도 호출할 수 있으니 순수 JS 라이브러리도 선택지입니다.
  • 허용 형식에 없는 바이너리는 압축 + base64 텍스트로 넣고, 불러올 때 해시로 검증합니다.
  • 문서의 한도보다 실제 한도가 낮을 수 있습니다. 직접 잰 값(이 스킬은 1 MB)을 빌드 검사에 넣으세요.
  • BOM은 SKILL.md 프런트매터와 입력 처리 양쪽에서 막습니다.
  • 스크립트 결과는 JSON으로 출력해, 에이전트가 경고를 사용자에게 풀어 전하게 합니다.
  • 샌드박스 조건(시스템 폰트 없음 등)을 환경 변수로 재현해 CI에서 패키지 단독 실행을 테스트합니다.

참고 문서

  1. https://www.microsoft.com/en-us/copilot/blog/copilot-studio/the-github-copilot-harness-in-copilot-studio-is-now-the-microsoft-copilot-harness/ ↩︎

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다