PodoLay

PodoLay 플러그인 SDK

SDK 1.9.0 · API 1.9 · JavaScript 로 편집기에 기능을 더합니다

PodoLay 플러그인 개발 안내 (Plugin API 1.7)

플러그인을 만드는 사람을 위한 안내다. SDK 는 개발자 문서나 포털 › 개발자 › SDK 내려받기 에서 받는다 (podolay-plugin-sdk-<판>.zip — 이 안내서가 그 안의 README.md 다. 영어판은 podolay-plugin-sdk-<판>-en.zip. 판 없는 podolay-plugin-sdk.zip·-en.zip 은 언제나 최신 판). 모든 API 의 자세한 설명은 API.md (SDK 안에도 있다) — 이 문서는 흐름과 규칙을 다룬다. 아래 명령은 SDK 를 푼 폴더에서 친다. 타입 정의 podolay-plugin.d.ts, 예제 examples/. (PodoLay 레포 안에서는 packages/plugin-sdk/, 설계는 docs/PluginPlan.md.)

1. 한눈에

  • 플러그인 = 폴더 하나: podolay-plugin.json(매니페스트) + main.js + (선택) 패널 html·아이콘.
  • main.js 는 편집기와 떨어진 샌드박스(불투명 출처 iframe 안의 Web Worker)에서 돈다. 편집기의 DOM·로그인 정보·네트워크에 닿을 수 없고, 전역 podolay 객체로만 편집기와 말한다. 모든 호출은 Promise 다.
  • 워커라서 document·window 가 없다 (self·OffscreenCanvas·setTimeout 등 워커 API 는 있다). 화면이 필요하면 패널을 쓴다.
  • 플러그인이 오래 막히면(그리기·툴·이벤트 호출에 8초 안에 답하지 못하고 살아 있는지 묻는 신호에도 답이 없으면) 편집기가 플러그인을 끈다. 무거운 계산은 잘게 나누거나 await 사이에 둔다.
  • 그래픽은 그림 목록(display list)으로 그린다. 편집기가 그 결과를 문서에 저장하므로, 플러그인이 없는 사람·독자 모드·PDF 에서도 똑같이 보이고 인쇄된다.
  • 문서를 바꾸는 일은 podolay.edit() 한 번이 Undo 한 단계다. 중간에 하나라도 틀리면 아무것도 바뀌지 않는다.

2. 시작하기

AI 코딩 에이전트(Claude Code·Codex·Antigravity·Cursor·VS Code Copilot)로 만든다면 설치부터 첫 요청까지 AI 에이전트로 만들기 를 따라 한다.

  1. 포털 › 내 정보 › 개발자 에서 개발자 신청 → 관리자 승인.
  2. 포털 › 개발자 › SDK 내려받기 로 받은 zip(podolay-plugin-sdk-<판>.zip)을 푼다 (Node 18 이상) — podolay-plugin-sdk-<판>/ 폴더가 생긴다(예: podolay-plugin-sdk-1.6.2/). 아래 node tools.mjs 는 이 폴더 안의 것. 새 SDK 로 올리기는 §8.9.1. 뼈대 만들기: node tools.mjs init my-plugin — 툴로 끌어 만드는 그래픽 플러그인. 메뉴·단축키 명령 플러그인은 --kind command. --id com.내도메인.이름(처음 올린 개발자의 것이 되므로 신중히), --name "이름" 도 줄 수 있다. 폴더에 podolay-plugin.json·main.js·podolay-plugin.d.ts(에디터 자동완성용)와, AI 에이전트·타입 검사용 AGENTS.md·CLAUDE.md·jsconfig.json·.gitignore (그래픽이면 render 시험 test.mjs)가 생긴다 — 개발용 파일은 올리는 zip 에 들어가지 않는다. 더 큰 예제는 examples/ (원고지·같은 칠 고르기(패널)·개체 번호 매기기(단축키만)·카탈로그 만들기(API 1.1 자동 조판·네트워크)).
  3. 검사: node tools.mjs check my-plugin — 편집기를 열지 않고 편집기·서버와 같은 규칙으로 매니페스트·정의·render·권한을 본다(§8.2). 개발 서버: node tools.mjs dev my-plugin (기본 포트 5199) — 플러그인 로그가 이 터미널에 찍히고, 파일을 고치면 편집기가 다시 불러온다.
  4. 편집기에서 돌려 보기 — 둘 중 하나:
    • 샌드박스 편집기(권장): node tools.mjs dev my-plugin --open — 로그인 없이, 서버에 아무것도 저장하지 않는 기본 설정의 새 문서로 편집기가 열리고 이 플러그인을 불러온다. 다른 터미널에서 tools.mjs test·run·state·shot 으로 조종한다(§8.10). AI 에이전트는 이쪽을 쓴다.
    • 내 문서에서: 편집기로 문서를 열고 주소 끝에 &pluginDev=http%3A%2F%2Flocalhost%3A5199%2Fpodolay-plugin.json 을 붙여 다시 연다. 승인된 개발자(또는 관리자) 계정, localhost 만 된다. 플러그인이 고친 것은 보통 편집처럼 저장된다.
  5. 고치면 편집기가 알아서 다시 불러온다(개발 서버가 파일 변경을 알린다). console.*·오류는 main.js:줄 과 함께 개발 서버 터미널과 편집기 › 플러그인 메뉴 › 로그 에 모인다. 중단점은 브라우저 개발자 도구 › Sources 의 <플러그인 id>/main.js 에(또는 코드에 debugger;) — §8 디버깅.
  6. 올리기: node tools.mjs pack my-plugin(먼저 check 를 돌려 오류면 멈춘다) → 포털 › 개발자 › 판 올리기 → 검수 신청. 승인되면 스토어(대시보드 › 플러그인)에 실린다. 이미 설치한 사람은 설치한 판을 계속 쓴다 — 새 판은 사용자가 대시보드 › 플러그인에서 업그레이드해야 쓴다(2026-10-27~, 저절로 오르지 않는다). 고칠 때는 version 을 올려 새 판으로 올린다 (판마다 검수).

개발 모드의 storage 는 서버에 쓰지 않고 그 창에만 남는다.

3. 매니페스트 podolay-plugin.json

필드뜻
id역도메인 (com.example.qr) — 처음 올린 개발자의 것이 된다
name, description글 또는 언어별 객체 { "en": …, "ko": … }
versionsemver (1.2.0) — 판마다 새로 검수
apiVersion"1.0"
minEditor이 판 이상의 편집기에서만 불러온다 (선택)
main샌드박스에서 돌릴 스크립트
permissionsdocument:read · document:write · selection · ui · storage · network:<호스트> (§7)
privacyUrl개인정보 처리방침 https 주소 — 네트워크 권한이 있으면 필수
contributes.graphics{type, name, usesFillStroke?, defaultSize?, inspector?} — 플러그인 그래픽 형식
contributes.tools{id, label, icon?, kind: 'createGraphic'·'custom', graphicType?} — 툴바 단추
contributes.menus{command, label} — 헤더 '플러그인' › 플러그인 이름 › 항목
contributes.keybindings{command, key} — mod+alt+q, alt+shift+f, f6 (수정 키 없는 글자는 안 됨)
contributes.panels{id, title, html, width?, height?} — 떠 있는 패널 창
  • mod = macOS ⌘, 그 밖 Ctrl. 편집기가 이미 쓰는 단축키(⌘F 처럼 ⌥·⇧ 를 가리지 않는 것 포함)는 붙지 않고 로그에 경고가 남는다.
  • 인스펙터 필드 inspector: [{key, label, type: number·text·checkbox·select·color, min?, max?, step?, unit?, options?}] — 플러그인 그래픽을 고르면 편집기 모양 그대로 나오고, 바꾸면 data[key] 가 바뀐 뒤 render 가 다시 불린다(Undo 한 단계).

4. API

// 그래픽 형식 — render 는 데이터와 크기만 보고 그리는 순수 함수
podolay.graphics.define('qr', {
  defaults: { text: 'https://podolay.com' },
  render(data, { width, height }) { return [{ t: 'rect', x: 0, y: 0, w: width, h: height, fill: '#000' }]; },
});

podolay.commands.define('regen', async () => { /* 메뉴·단축키 */ });
podolay.tools.define('lasso', { onDown(e) {}, onMove(e) { return previewItems; }, onUp(e) {} });   // kind: 'custom'

const ids = await podolay.selection.get();                    // selection
const g = await podolay.doc.graphic(ids[0]);                  // document:read
const { created } = await podolay.edit('정렬', tx => {        // document:write
  tx.set(g.id, { frame: { ...g.frame, x: 0 } });
  tx.create({ kind: 'text', frame: { x: 0, y: 0, width: 100, height: 20 }, text: '안녕' });
  tx.setPluginData(g.id, { tag: 'hero' });                    // 아무 개체에나 이 플러그인의 속성
});
await podolay.ui.toast('끝');                                 // 권한 없이
await podolay.ui.showPanel('main');                           // ui
await podolay.storage.set({ last: Date.now() });              // storage (계정별, 64KB)
podolay.on('selectionChange', ({ ids }) => {});               // selectionChange · documentChange · historyChange · panelClose
  • 좌표는 모두 pt, 개체 프레임은 스프레드 좌표다. doc.info() 가 쪽마다 스프레드 안 자리를 준다.
  • tx.set 으로 바꿀 수 있는 것: frame·rotation·opacity·visible·locked·fill·stroke (그 밖은 다음 API 판에서).
  • tx.create 종류: plugin(자기 그래픽 형식)·rectangle·ellipse·line·text(서식 없는 text 또는 1.1 의 story).
  • tx.remove 는 스프레드에 바로 든 개체만(그룹 안 개체는 그룹째로). 잠긴 개체는 지우지 않는다.

4.1 글과 템플릿 채우기 (API 1.1)

매니페스트에 "apiVersion": "1.1" 을 적어야 쓸 수 있다(1.0 플러그인이 부르면 오류). 1.1 을 모르는 옛 편집기는 그 플러그인을 불러오지 않는다.

const { para, char } = await podolay.doc.styles();           // [{id, name, key?}] — 내장 '기본' 은 key '$base'
const slots = await podolay.doc.find({ text: '{{title}}' });   // 글에 자리표시자가 든 텍스트 개체
const tagged = await podolay.doc.find({ tag: 'price', page: 0 });   // 이 플러그인이 setPluginData 로 표시한 개체
const story = await podolay.doc.text(slots[0].id);            // [{ para, runs: [{ text, char?, font?, size?, color?, bold?, italic? }] }]

await podolay.edit('카탈로그 채우기', tx => {
  tx.setText(slots[0].id, '새 제목');                          // 글 전체 — 지금 글의 첫 서식을 그대로 쓴다
  tx.replaceText(slots[1].id, '{{price}}', '12,000');         // 자리표시자만 — 주변 서식 그대로
  tx.create({ kind: 'text', frame: { x: 40, y: 40, width: 300, height: 120 }, story: [
    { para: '제목', text: '봄 신상품' },
    { para: '$base', runs: [{ text: '정가 ' }, { text: '12,000원', char: '강조', color: { cmyk: [0, 100, 100, 0] } }] },
  ] });
});
  • Story = 문자열(줄마다 단락) 또는 단락 배열 { para?, runs: [...] } / { para?, text }. 런 안의 줄바꿈은 빈칸이 된다(단락은 배열로 나눈다).
  • 서식 순서: 바탕 → 단락 스타일(+그 단락 스타일의 문자 스타일) → 런의 문자 스타일 → 런의 직접 서식(font·size·color·bold·italic). 바탕은 새 글이면 문서의 '기본' 스타일, setText 면 지금 글의 첫 단락·첫 글자 서식이다 — 디자이너가 템플릿에 입혀 둔 서식이 남는다.
  • 스타일은 문서에 있는 이름(또는 id). 없는 이름이면 편집 전체가 실패한다(틀린 채 채워지지 않게). 내장 스타일은 '$base'·'$none'.
  • 연결된 텍스트 프레임은 어느 프레임 id 를 줘도 글 전체(체인)를 다룬다. doc.find({ text }) 도 체인당 한 번만 잡는다.
  • replaceText 는 한 서식 덩어리 안에 다 든 자리표시자만 찾는다(중간에 서식이 바뀌면 못 찾는다). 페이지 번호·각주 같은 변수 런은 건드리지 않는다.
  • 개체에는 이름이 없다. 템플릿 자리는 글 속 자리표시자({{field}})나, 플러그인 패널에서 고른 개체에 setPluginData 로 붙인 표시로 찾는다.
  • 한 글은 20만 자, 1만 단락까지.

4.2 그림 (API 1.1)

const res = await fetch('https://cdn.example.com/p/123.jpg');        // network 권한 (§7) — 또는 OffscreenCanvas 로 그린 것
const { asset, width, height } = await podolay.assets.upload(await res.arrayBuffer(), { name: '123', type: 'image/jpeg' });
await podolay.edit('사진 넣기', tx => {
  tx.setImage(slotId, asset, 'fill');                                      // 도형·그림 프레임에 — 모양 안에 잘려 그려진다
  tx.create({ kind: 'image', frame: { x: 40, y: 300, width: 200, height: 150 }, asset, fit: 'fit' });
});
  • assets.upload 는 사용자 계정으로 이 문서의 이미지 자산을 만든다(document:write). 요금제 저장 한도·형식 검사는 보통 업로드와 같고, 인쇄에는 원본이 그대로 간다. png·jpeg·gif·tiff 는 그대로, webp 는 PNG 로 바뀐다. 30MB 까지.
  • tx 의 asset 은 그 창에서 이 플러그인이 올린 것이거나 문서에 이미 있는 그림의 경로만 된다.
  • fit: fill(기본, 비율대로 프레임을 덮고 넘친 곳은 잘림)·fit(다 보이게)·stretch(비율을 버림). 놓은 뒤에는 사용자가 인스펙터의 프레임 맞춤으로 바꿀 수 있다.

4.3 쪽 편집 (API 1.1)

const info = await podolay.doc.info();            // spreads[].pages[]: {index, label, master, …}, masters: [{id, name}]
const r = await podolay.edit('카탈로그 쪽', tx => tx.duplicatePages({ from: 2, times: items.length }));   // 템플릿 쪽(2)을 상품 수만큼
const { pages, copies } = r.results[0];           // copies[k] = { 원본 개체 id: k 번째 사본의 개체 id }
await podolay.edit('카탈로그 채우기', tx => items.forEach((it, k) => {
  tx.setText(copies[k][titleId], it.name);         // 템플릿에서 찾아 둔 원본 id → 사본 id
  tx.setImage(copies[k][photoId], it.asset);
}));
await podolay.edit('쪽 정리', tx => {
  tx.addPages({ after: info.spreads.at(-1).pages.at(-1).index, count: 2, master: 'A-Master' });   // master: id·이름, null = 없음
  tx.applyMaster([0], null);
  tx.removePages([2]);                              // 템플릿 쪽은 다 쓰고 지운다
});
  • 쪽 번호는 0부터, op 를 부르는 때의 번호다(앞 op 가 쪽을 늘리거나 지우면 밀린다). 결과의 pages 는 편집이 다 끝난 뒤의 번호다.
  • addPages 의 master 를 빼면 이웃 쪽의 마스터를 따른다. duplicatePages 는 개체·마스터·지운 마스터 항목까지 복사한다.
  • 쪽 op 가 든 edit 는 문서 전체 스냅숏 하나로 Undo 된다(같은 edit 안의 다른 op 도 함께). 실패하면 아무것도 바뀌지 않는다.
  • 요금제의 문서당 쪽 수 한도를 넘으면 편집 전체가 실패한다. 한 edit 에서 늘릴 수 있는 쪽은 1000쪽, 모든 쪽을 지울 수는 없다. 마스터 편집 중에는 안 된다.
  • 복제와 채우기처럼 여러 edit 를 한 단계로 묶으려면 podolay.batch (§4.5).

4.4 글 흐름 (API 1.1)

const st = await podolay.doc.textStatus(bodyId);  // { frame, frames, lines, chars: [시작, 끝), length, height, overflow }
if (st.overflow) {
  const r = await podolay.edit('이어 받을 프레임', tx => tx.create({ kind: 'text', frame: next, story: '' }));
  await podolay.edit('잇기', tx => tx.link(st.frames.at(-1), r.created[0]));   // 체인 끝 → 빈 프레임
}
const { height, lines } = await podolay.text.measure(story, 180, { like: bodyId });   // 문서를 바꾸지 않고 재 본다
  • textStatus 는 화면과 같은 체인 레이아웃에서 읽는다. chars 로 이 프레임에 든 글자 범위를, overflow 로 체인 끝 넘침을 안다.
  • link(from, to): from 은 체인의 끝(다음이 없어야), to 는 다른 글에 속하지 않은 빈 텍스트 프레임. unlink(from) 은 from 뒤를 끊는다(뒤 프레임들의 글은 앞으로 모인다). 제목용 텍스트·패스 위 글은 이을 수 없다.
  • text.measure(story, width, { like }) 는 폭 width(pt, 프레임 안쪽 여백을 뺀 글 폭)에 짜 본 높이와 줄 수. like 를 주면 그 글의 첫 서식을 바탕으로(= setText 와 같은 서식). 지금 불러온 서체로 잰다 — 문서에 아직 쓰지 않은 서체·글자는 처음엔 대체 서체로 재일 수 있다. 확정은 넣은 뒤 textStatus 로.

4.5 일괄 작업 (API 1.1)

await podolay.batch('카탈로그 만들기', async (signal) => {
  const r = await podolay.edit('복제', tx => tx.duplicatePages({ from: 2, times: items.length }));
  for (let k = 0; k < items.length; k++) {
    signal.throwIfAborted();                                  // 사용자가 취소했으면 멈춘다
    await podolay.ui.progress({ label: items[k].name, value: k + 1, max: items.length });
    const { asset } = await podolay.assets.upload(await fetchImage(items[k]), { type: 'image/jpeg' });
    await podolay.edit('채우기', tx => { tx.setText(r.results[0].copies[k][titleId], items[k].name); tx.setImage(r.results[0].copies[k][photoId], asset); });
  }
});                                                            // 사용자에게는 Undo 한 단계 '카탈로그 만들기'
  • 안의 edit 들은 바로 화면에 보이지만 Undo 기록·서버 저장은 끝날 때 한 번(문서 전체)이다. fn 이 던지거나 사용자가 진행 창에서 취소하면 시작 전으로 되돌아가고 batch 가 오류를 던진다(올린 그림 자산은 남는다 — 문서에서 쓰지 않으면 갤러리에서 지울 수 있다).
  • 그동안 편집기는 진행 창으로 덮여 사용자가 편집할 수 없다. 취소 뒤 5초 안에 끝나지 않으면 편집기가 되돌린다. 다른 플러그인은 그동안 문서를 고칠 수 없다.
  • ui.progress({ label, value, max }) 로 진행 창의 글·막대를 바꾼다(batch 안에서만). max 가 없으면 막대가 오가는 모양이다.
  • 겹친 batch 는 안 된다. 쪽 수 한도·각 edit 의 규칙은 그대로다.
  • 총 시간 한도는 없다(API 1.6 편집기부터). 그 플러그인의 batch 가 도는 동안은 명령(60초)·패널 메시지(8초)도 시간을 넘겨 실패하지 않고, 안의 edit 는 화면을 250ms 에 한 번만 다시 그린다. edit 하나에 레코드 수십 개씩 묶는 것이 알맞다. Undo 는 시작·끝 문서 스냅숏 두 벌이다.
  • 전체 흐름의 예제: examples/catalog — 템플릿 쪽의 {{필드}} 글과 '사진 자리'(패널 단추로 setPluginData)를 상품마다 복제해 채운다. 데이터는 예제 온라인 API(dummyjson.com, network: 권한) 또는 붙여 넣은 JSON. 오래 걸리는 일은 패널 메시지 처리에서 await 하지 말고 따로 돌린다 (편집기가 플러그인 호출마다 8초 시간 제한을 둔다).

4.6 글 속 개체 (API 1.2)

매니페스트에 "apiVersion": "1.2" 를 적는다. 그림·도형·자기 그래픽을 글 속에(인라인 앵커) 넣으면 글자 한 자리를 차지하고 글과 함께 흐른다.

await podolay.edit('아이콘 넣기', tx => {
  tx.insertInline(bodyId, '{{icon}}', { kind: 'plugin', type: 'icon', data: { name: 'star' }, width: 12, height: 12 });  // 자리표시자마다
  tx.insertInline(bodyId, null, { graphic: logoId });                     // 스프레드의 개체를 글 끝으로 옮김 (id 는 없어진다)
  tx.setText(captionId, [{ runs: [{ inline: { kind: 'image', asset, width: 40, height: 30 } }, { text: ' 그림 1' }] }]);
});
const list = await podolay.doc.inlines(bodyId);                           // [{ index, kind, width, height, offset, placed }, …]
await podolay.edit('빼기', tx => tx.removeInline(bodyId, list[0].index));
  • 넣는 자리: 자리표시자 글(모두) · 글자 자리(Graphic.text 기준) · null = 글 끝. Story 런 { inline } 으로도 넣는다.
  • 1.2 에서 doc.text 는 글 속 개체를 런 { inline: { index, … } } 로 준다 — 그대로 setText 에 돌려주면 그 개체를 잃지 않는다.
  • 글 속 개체에는 id 가 없다 — 다시 찾으려면 넣을 때 pluginData 를 붙이고 doc.inlines 로 읽는다. 자세한 규칙은 API.md §9.14·§16.8.

4.7 우클릭 메뉴 (API 1.2)

매니페스트 menus[] 에 context 를 적으면 헤더의 플러그인 메뉴 대신(또는 함께) 우클릭 메뉴에 나온다. "apiVersion": "1.2".

"menus": [{ "command": "makeQr", "label": "QR 코드로", "context": "object", "when": { "kind": ["rectangle"], "single": true } },
          { "command": "lookup", "label": "고른 글 찾아보기", "context": "text" }]
podolay.commands.define('makeQr', async ({ ids }) => { … });                  // 고른 개체
podolay.commands.define('lookup', async ({ id, selection }) => { … selection.text … });
  • context: header(기본) · object(개체) · text(글 편집 중) · canvas(빈 곳). when 은 object 메뉴를 보일 조건(종류·자기 그래픽 형식·하나만).
  • 명령은 고른 자리를 인자로 받는다 — 개체 id 들, 우클릭한 자리(스프레드 좌표), 글 편집이면 고른 글자 자리(Graphic.text 기준). 자세히는 API.md §3.5·§5.3.
  • 시험: 모의 편집기와 tools.mjs test 의 손잡이에 p.menuItems(context, ids)(그 자리에 나올 항목)·p.runMenu(command, { context, ids | id·start·end | point }).

4.8 자연 크기·글 속 개체 고치기 (API 1.3)

"apiVersion": "1.3". 수식처럼 데이터가 크기를 정하는 그래픽은 매니페스트 graphics[] 에 "autoSize": true 를 적고 size(data, frame) 을 정의한다. 편집기가 데이터를 바꿀 때(인스펙터 칸·tx.setData·툴로 만들 때·tx.setInline) 그 크기로 프레임을 맞추고 그 크기로 render 한다(한 Undo 단계).

podolay.graphics.define('math', {
  defaults: { tex: 'x^2' },
  size: (data, frame) => ({ width: measure(data.tex), height: 14 }),   // x·y 를 빼면 왼쪽 위를 지킨다, null = 그대로
  render(data, { width, height }) { … },
});
await podolay.edit('고치기', tx => tx.setInline(bodyId, 0, { data: { tex: 'x^3' } }));     // 글 속 개체를 제자리에서 (autoSize 면 크기도)
await podolay.edit('수식으로', tx => tx.replaceRange(bodyId, sel.start, sel.end,
  { inline: { kind: 'plugin', type: 'math', data: { tex }, width: 30, height: 14, baseline: 3 } }));   // 고른 글자 범위를 개체로
  • 인스펙터 칸 live: false(숫자 칸만 — 스테퍼를 멈춘 뒤 한 번 바꾼다)·hideInline: true(글 속 개체로 고를 때 숨긴다).
  • 글 속 개체의 baseline = 아래 끝에서 글의 기준선까지(pt) — 분모·첨자가 기준선 아래로 내려간다.
  • 이벤트 dataChange { id, before, after, origin } — 자기 개체의 데이터가 바뀔 때(인스펙터·tx.setData·Undo·Redo). 자세히는 API.md §5.1·§6·§9.15·§16.8.

4.9 글 편집 중 명령·범위 서식·글 자리·패키지 파일 (API 1.4)

"apiVersion": "1.4". 글을 고쳐 주는 플러그인(발음기호·표기 바꾸기·맞춤법)을 위한 것들이다.

"keybindings": [{ "command": "ipa", "key": "ctrl+alt+c" }],
"menus":       [{ "command": "ipa", "label": "IPA 로", "context": ["header", "text"] }]
let table;
podolay.commands.define('ipa', async (arg) => {
  if (arg?.context !== 'text') return;                              // 글 편집 중이면 헤더 메뉴·단축키도 text 인자
  table ??= await podolay.package.json('data/ipa.json');            // 패키지 안 데이터 파일
  const { start, end, text } = arg.selection;
  await podolay.edit('IPA', tx => tx.replaceRange(arg.id, start, end, { runs: [{ text: convert(text, table), font: 'Charis SIL' }] }));
});   // 끝나면 편집기가 넣은 글 바로 뒤에 캐럿을 두고 편집을 다시 연다
  • 글 편집 중 명령 — 헤더 메뉴·단축키로 부른 명령도 글 상자·표 칸을 편집 중이면 우클릭 글 메뉴와 같은 text 인자를 받는다. 편집기는 편집을 끝내고 부른 뒤 편집을 다시 연다 (글이 그대로면 원래 선택, 바뀌었으면 늘어난 만큼 뒤에 캐럿). 플러그인이 selection.setText(id, start, end?) 로 다른 곳을 고르면 그 선택이 남는다.
  • 단축키 수식키 ctrl = Mac 의 ⌃(그 밖은 Ctrl) — mod(⌘)로는 ⌃⌥C 같은 조합을 적을 수 없었다. 플러그인 단축키는 글 편집 중에도 듣는다.
  • podolay.text.selection()·이벤트 textSelectionChange — 편집 중인 글의 선택 { id, start, end, text }(권한 selection). 패널에 미리보기를 보일 때.
  • 범위 서식 — tx.replaceRange(id, s, e, { runs })(start 글자의 서식 위에 런마다 문자 스타일·직접 서식), tx.formatRange(id, s, e, { char?, font?, size?, color?, bold?, italic? })(글은 그대로, 결과 = 서식 입힌 글자 수).
  • 글 자리 — 글 id 를 받는 곳이 표 칸 { id: 표, cell: [행, 열] }·글 속 텍스트 프레임 { id: 글, inline: 서수 } 도 받는다. doc.texts() 가 문서의 모든 글 자리를 준다.
  • 패키지 파일 — podolay.package.read(path)(→ ArrayBuffer)·text·json. 패키지 안 상대 경로, 20MB 까지, 확장자에 bin·csv·tsv 도. 코드는 import 할 수 없으니 빌드로 main.js 에 묶는다.
  • 시험: 모의 편집기의 p.editText(id, start, end)(사용자가 글을 편집) 뒤 p.run(command) — 인자·편집 되살리기·textSelectionChange 를 편집기처럼. 자세히는 API.md §5.3·§7.12·§7.13·§8.3·§9.16·§14·§16.9.

4.10 데이터베이스 퍼블리싱 — 서식째 글 복사·표 행·파일 고르기·자동 흐름 (API 1.6)

"apiVersion": "1.6" (편집기 2.14.12 부터). 데이터(CSV·JSON·사진 폴더)로 템플릿을 되풀이해 카탈로그·명단·가격표를 짜는 플러그인을 위한 것들이다.

const [csv] = await podolay.ui.pickFiles({ accept: '.csv' });             // 사용자 PC 의 파일 — 편집기가 고르기 창을 띄운다
const photos = await podolay.ui.pickFiles({ directory: true });         // 폴더째 ([{ name, path, type, size, bytes }])
const records = parseCsv(new TextDecoder().decode(csv.bytes));
await podolay.batch('명단', async (signal) => {                          // 총 시간 한도 없음
  for (const rec of records) {
    signal.throwIfAborted();
    const photo = photos.find(f => f.name === rec.photo);
    const { asset } = await podolay.assets.upload(photo.bytes, { name: rec.photo.replace(/\.[^.]+$/, ''), type: photo.type, reuse: 'name' });
    const r = await podolay.edit('복제', tx => tx.copyRange({ id: tplId, start: 0, end: tplLen }, { id: bodyId, newParagraph: true }));
    const { start, inlines } = r.results[0];
    await podolay.edit('채우기', tx => {
      tx.setImage({ id: bodyId, inline: inlines[0], path: [1] }, asset, 'fit');   // 복제된 글 속 그룹의 둘째 자식
      fillTags(tx, bodyId, start, rec);                                             // <field> 자리를 replaceRange 로 (뒤에서부터)
    });
  }
  await podolay.edit('흘리기', tx => tx.autoflow(bodyId));               // 넘친 글을 새 쪽·프레임으로
});
  • tx.copyRange(from, to) — 글 범위를 단락 스타일·문자 스타일·직접 서식·글 속 개체(그룹·글 속 표까지)째 복제해 끼운다. 단락 서식은 단락 끝(\n)을 따라간다 — 단락째 옮기려면 범위에 단락 끝을 넣는다. 결과 { start, end, inlines, dropped }(끼운 범위·그 안 글 속 개체 서수·빼낸 각주/미주 참조 수). 같은 edit 의 뒤 op 는 복제된 글을 본다.
  • 글 속 개체 주소 { id: 글, inline: 서수, path?: [자식 번호, …] } — 글 속 그룹 안의 자식까지 tx.set·setPluginData·setImage·doc.graphic·doc.table 과 글 자리({ id, inline, path, cell? })로 다룬다. doc.inlines·doc.graphic 이 그룹 자식에 path 를 붙여 준다.
  • 표 행 — doc.table(target)(행·열·머리행·꼬리행·병합·크기 — footerRows 는 편집기 2.15.0 부터 실제 꼬리행 수), tx.insertRows(target, { after, count?, copyFrom? })(템플릿 행을 서식·글·배경·높이·경계선째 되풀이), tx.removeRows(target, at, count?).
  • 단락째 끼우기 — tx.appendText(id, story, { newParagraph? })·tx.replaceRange(id, s, e, { story }). Story 의 20만 자·1만 단락은 입력 한 번의 한도라 여러 번 붙여 늘릴 수 있다. 넘치면 tx.autoflow(id, { master?, maxPages? }) 가 쪽을 끼우며 프레임을 잇는다(쪽 op).
  • 파일 — podolay.ui.pickFiles({ accept?, multiple?, directory? })(5000개·1GB 까지, batch 전에). 패널이 있으면 패널의 <input type="file">·끌어 놓기로 읽어 podolayUI.postMessage(msg, [바이트…]) 로 넘겨도 된다(ArrayBuffer 는 메시지 글자 수에 세지 않는다).
  • 자산 — assets.list({ name? })(문서의 그림 자산), assets.upload 의 reuse: 'name'(같은 이름·크기면 다시 쓰기)·결과의 sha256·reused. 창을 넘는 내용 중복은 sha256 → asset 표를 setDocData 에 적어 둔다.
  • 서식 — Story 런 tracking·hScale·vScale·baselineShift·underline·strike·position·smallCaps, 단락 align·leading·spaceBefore·spaceAfter·indentLeft·indentRight·indentFirst. doc.text 는 기본값(단락은 단락 스타일)과 다를 때만 싣는다.
  • 그 밖 — tx.duplicate(id, { spread?, dx?, dy? })(결과 { id, copies }), duplicatePages 의 copies 에 그룹 자식도. doc.find·doc.texts·doc.graphics 는 대지·숨긴 개체·숨긴 레벨의 개체도 찾는다(마스터는 masters: true 일 때만). 자세히는 API.md §7.14·§9.17~§9.21·§10·§11.5·§13·§16.6·§16.10.

5. 그림 목록

[
  { t: 'rect', x, y, w, h, fill: '#ff0000', stroke: { color: { cmyk: [0, 0, 0, 100] }, width: 1, dash: [3, 2] } },
  { t: 'ellipse', cx, cy, rx, ry, fill: { gray: 30 } },
  { t: 'line', x1, y1, x2, y2, stroke: { color: '#000', width: 0.5, cap: 'round' } },
  { t: 'path', d: 'M0 0 L10 0 Q20 5 10 10 Z', fill: '#09f', rule: 'evenodd' },
  { t: 'group', opacity: 0.5, items: [ … ] },
]
  • 좌표 = 개체 로컬 pt (원점 = 프레임 왼쪽 위). 그린 때와 크기가 다르면 편집기가 늘려 그리고, 플러그인이 있으면 새 크기로 다시 그리게 한다.
  • 색: '#rrggbb'(RGB) · {cmyk:[c,m,y,k]}(0–100, 인쇄 잉크 그대로) · {gray:k}(K 잉크).
  • 경로: SVG M L H V C S Q T A Z (대소문자, 원호 플래그를 붙여 써도 된다). 원호는 3차 곡선으로 바뀐다(옛 편집기 판에서는 직선).
  • 상한: 항목 5000개, 경로 글자 합 100만, 묶음 깊이 8. 넘거나 틀리면 그리기가 거절되고 로그에 이유가 남는다.
  • 1.0 에는 글·그림 항목이 없다. 글이 필요하면 tx.create({ kind: 'text' }) 로 보통 텍스트 프레임을 만든다.

6. 패널

패널 html 은 플러그인과 같은 샌드박스 규칙(네트워크 없음, 인라인 스크립트·스타일만, 그림은 data:)으로 뜬다. 패널은 편집기 API 를 직접 부르지 않고 자기 main.js 와만 말한다:

// panel.html
podolayUI.postMessage({ type: 'apply', value: 3 });
podolayUI.onMessage(msg => { … });
// main.js
podolay.ui.onMessage((msg, panelId) => { … });
await podolay.ui.postMessage('main', { count: 3 });
  • 패널 안에서는 <input type="file">(multiple·webkitdirectory)·끌어 놓기·FileReader 가 된다. 읽은 바이트는 podolayUI.postMessage(msg, transfer) 로 복사 없이 넘긴다(API 1.6 편집기, ArrayBuffer 는 400만 자 한도에 세지 않는다). 패널이 없으면 podolay.ui.pickFiles (API.md §11.5).

7. 네트워크 (network:<호스트>)

"permissions": ["document:read", "network:api.example.com", "network:*.cdn.example.com"],
"privacyUrl": "https://example.com/privacy"
  • 적은 호스트의 https/wss 로만 나갈 수 있다. *. 는 맨 앞 한 단계(하위 도메인), :8443 처럼 포트를 붙일 수 있다. 10개까지. IP 주소·localhost·사설 이름(.local·.internal 등)·*.com 같은 넓은 와일드카드는 받지 않는다.
  • 쓸 수 있는 것: fetch, WebSocket, EventSource. XMLHttpRequest·importScripts·워커 만들기는 늘 막혀 있다(코드를 내려받아 돌릴 수 없다).
  • 요청에는 쿠키·리퍼러가 붙지 않고 Origin 이 null 이다 — **상대 API 가 CORS 로 Access-Control-Allow-Origin: *(또는 null)을 보내야** 한다. 인증은 헤더의 토큰으로. API 키를 main.js 에 넣지 말 것(누구나 볼 수 있다) — 사용자가 패널에서 자기 키를 넣어 storage 에 두게 한다.
  • DB 에 바로 붙을 수는 없다(브라우저 코드). DB 앞에 HTTP API 를 둔다.
  • 개발 모드(pluginDev)에서는 네트워크 권한이 하나라도 있으면 http://localhost:*·127.0.0.1 도 열린다(모의 API 시험용). 올린 판에서는 안 된다.
  • 설치하는 사람에게 "문서 내용을 이 서버로 보낼 수 있습니다"와 호스트 목록이 보인다. 새 판에서 네트워크 권한을 늘리면 사용자가 업그레이드하며 다시 동의할 때까지 편집기는 옛 판을 계속 쓴다(판은 늘 사용자가 업그레이드해야 오른다).
  • 로그 창에 요청마다 [network] GET api.example.com → 200 이 남는다(경로·본문은 남기지 않는다).

8. 디버깅

8.1 준비

  • 개발은 개발 모드에서 한다: node tools.mjs dev my-plugin 을 띄우고 편집기 주소 끝에 &pluginDev=http%3A%2F%2Flocalhost%3A5199%2Fpodolay-plugin.json. 승인된 개발자(또는 관리자) 계정, 이 컴퓨터(localhost)의 주소만 된다. 불러오면 "개발 중 플러그인을 불러왔습니다" 알림이 뜬다.
  • 시험은 시험용 문서에서 — 플러그인이 고친 것은 보통 편집처럼 저장된다(Undo 로 되돌릴 수 있다). 더 간단히는 샌드박스 편집기(§8.10): node tools.mjs dev my-plugin --open 이면 로그인 없이, 서버에 아무것도 저장하지 않는 새 문서로 편집기가 열린다.
  • 개발 모드의 storage 는 서버에 쓰지 않고 그 창에만 남는다(새로고침하면 비워진다).

8.2 편집기 없이 검사 — tools.mjs check

node tools.mjs check my-plugin            # ✗ 오류 / ! 경고 / 통과 — 오류면 종료 코드 1
node tools.mjs check my-plugin --json     # { errors, warnings, info } — 에이전트·CI 용
보는 것예
매니페스트 (편집기·서버 규칙)틀린 필드, 없는 파일(main·아이콘·패널 html), 단축키 문법, 툴의 graphicType, network·privacyUrl
main.js 실행podolay 대역으로 맨 위 코드를 돌려 본다 — 구문·실행 오류(main.js:줄)
정의메뉴·단축키의 명령, custom 툴, 그래픽의 render 가 정의되었는가
renderdefaults 로 1×1·100×100·기본 크기·2000×1200 에서 불러 그림 목록 규칙·같은 입력에 같은 그림·시간을 본다
코드와 권한쓰는 API 의 권한이 없음(오류)·쓰지 않는 권한(경고), API 1.1~1.6 기능인데 apiVersion 이 낮음, async edit fn, DOM
자연 크기 (API 1.3)autoSize 인데 size 가 없음·그 반대(경고), size(defaults) 의 답이 편집기가 받는 꼴인가

규칙은 편집기와 같다(편집기 쪽 시험이 둘을 대조한다). 네트워크·타이머·문서 내용이 필요한 동작은 편집기에서 본다.

8.3 개발 서버 터미널과 자동 다시 불러오기

node tools.mjs dev my-plugin 을 띄우고 편집기를 pluginDev 로 한 번 열어 두면:

  • 플러그인의 로그 창에 쌓이는 모든 것(아래 §8.4)이 개발 서버 터미널에도 찍힌다 — 21:04:12 [error] command run: TypeError: … (com.example.qr/main.js:5:12). 편집기가 붙으면 편집기가 연결되었습니다, 불러오면 불러옴 com.example.qr 0.1.1 — 편집기 2.11.0, API 1.1.
  • 폴더의 파일이 바뀌면 편집기가 1초 안에 알아채 다시 불러온다(매니페스트 포함). 일괄 작업 중이면 끝난 뒤에.
  • --quiet = 로그를 찍지 않음, --no-watch = 자동 다시 불러오기 끔. 백그라운드로 돌려 파일로 받을 수도 있다: node tools.mjs dev . > dev.log 2>&1 &.

AI 에이전트(Claude Code 등)로 개발할 때는 이 고리 — 고치기 → check → node test.mjs → 샌드박스 편집기에서 tools.mjs test·run·shot(§8.10) — 를 쓴다. init 이 만든 AGENTS.md·CLAUDE.md 에 명령·규칙이 정리되어 있다.

8.4 로그 창

편집기 › 플러그인 메뉴(퍼즐) › 로그: 플러그인 이름. 같은 내용이 브라우저 콘솔과 개발 서버 터미널에도 찍힌다.

모이는 것예
console.log·info·warn·error객체는 JSON 으로, Error 는 스택으로
플러그인 코드의 오류 (자리 포함)command boom: TypeError: … (com.example.qr/main.js:5:12)
잡지 못한 오류·Promise 거절Uncaught ReferenceError: … (com.example.qr/main.js:8) · Uncaught (in promise) …
시작 실패 (맨 위 오류·구문 오류)Unexpected token ';' (com.example.qr/main.js:12)
편집기가 거절한 호출render: 그림 목록이 올바르지 않습니다 — …, 권한이 없습니다: document:write …
시간 제한·멈춤플러그인이 시간 안에 답하지 않았습니다, 응답이 없어 플러그인을 껐습니다
네트워크 요청[network] GET api.example.com → 200 (경로·본문은 남기지 않는다)
단축키 경고단축키 mod+f 는 편집기가 이미 씁니다 — 붙이지 않았습니다

최근 200줄까지, 한 줄은 2000자에서 자른다.

8.5 브라우저 개발자 도구

플러그인 코드는 편집기 안의 숨은 샌드박스 iframe 에서 띄운 Web Worker 에서 돈다. Chrome 개발자 도구로 보통 스크립트처럼 디버깅한다.

하려는 것하는 법
중단점Sources › 스레드·파일 목록에서 <플러그인 id>/main.js(예 com.example.qr/main.js) — 실제 줄 번호 그대로다. 또는 코드에 debugger;
API 를 직접 불러 보기Console 위쪽의 실행 컨텍스트를 그 워커로 바꾸고 await podolay.doc.info(), await podolay.selection.get() …
네트워크Network 에 워커의 fetch 가 보인다. CORS 로 막히면 콘솔에 까닭이 나온다
패널패널은 따로 된 iframe 이다 — Elements 로 검사하고, 콘솔 컨텍스트를 그 iframe 으로 바꿔 podolayUI 를 부른다

중단점에 오래 멈춰 있으면 그사이 편집기가 보낸 호출(render·이벤트)은 8초 제한에 걸려 실패로 로그에 남는다. 멈춘 채로는 '응답 없음'으로 꺼지지 않게, 조사가 끝나면 바로 이어 실행한다(꺼졌으면 다시 불러오기).

8.6 고치고 다시 불러오기

고친 것할 일
main.js, 패널 html, 아이콘, 매니페스트아무것도 — 개발 서버가 알려 편집기가 다시 불러온다 (--no-watch 면 플러그인 메뉴 › 개발 중 플러그인 다시 불러오기)
툴 단추·메뉴가 이상하게 남았을 때편집기 탭 새로고침
개발 서버 포트·주소편집기 주소의 pluginDev 를 바꿔 다시 연다

다시 불러와도 문서에 이미 만든 플러그인 개체의 그림은 그대로다 — 데이터를 바꾸거나 크기를 바꾸면 새 render 로 다시 그려진다.

8.7 자주 보는 오류

메시지 (일부)까닭과 고치는 법
플러그인 매니페스트가 올바르지 않습니다: …알림에 틀린 필드가 나온다(permissions[2], contributes.tools[0].graphicType …). API.md §3
개발 중 플러그인은 승인된 개발자 계정에서만 …포털 › 개발자에서 신청·승인
개발 중 플러그인을 불러오지 못했습니다개발 서버가 떠 있는지, 주소가 …/podolay-plugin.json 인지
권한이 없습니다: …매니페스트 permissions 에 그 권한을 더하고 새로고침. API.md §2
… 는 apiVersion 1.1 이상에서 씁니다매니페스트 "apiVersion": "1.1" (1.2~1.6 이상이라는 오류면 그 판)
render: 그림 목록이 올바르지 않습니다 — …항목 형식·색 형식·제한(항목 5000, 경로 글자 수 …)을 넘었다. API.md §17
플러그인이 시간 안에 답하지 않았습니다호출이 8초를 넘었다 — render 를 가볍게, ui.onMessage 처리기에서 오래 걸리는 일을 await 하지 말고 따로 돌린다
플러그인 명령이 실패했습니다명령이 던졌거나 60초를 넘었다 — 로그의 자리를 본다. 긴 일은 batch 로
응답이 없어 플러그인을 껐습니다동기 코드(무한 루프·큰 계산)가 워커를 막았다 — 잘게 나눠 await 사이에. 다시 불러오기로 다시 켠다
허용되지 않은 주소입니다: …매니페스트에 network:<호스트> 가 없거나 https 가 아니다. §7
Failed to fetch (콘솔에 CORS)상대 서버가 Origin: null 을 허용하지 않는다 — 서버의 CORS 를 고치거나 다른 API
열린 패널이 없습니다ui.postMessage 전에 ui.showPanel. 사용자가 닫았으면 panelClose 이벤트로 안다
단락 스타일이 없습니다: … / 쓸 수 없는 그림입니다: …스타일 이름(내장은 '$base')·그림 경로(assets.upload 결과)를 확인
잠긴 개체는 … / 요금제의 문서당 쪽 수 한도 …사용자의 문서 상태 — 플러그인이 알림으로 사용자에게 알린다

edit·batch 는 실패하면 아무것도 바뀌지 않으니, 실패한 뒤 문서를 따로 되돌릴 필요는 없다.

8.8 모의 편집기로 단위 시험 — podolay-mock.mjs

편집기를 열지 않고 명령·edit·batch·render 로직을 Node 에서 시험한다. SDK 의 podolay-mock.mjs 가 메모리 안의 작은 문서에 podolay API 를 편집기와 같은 규칙으로 흉내 낸다 — 권한·apiVersion 검사, edit 은 전부 되거나 아무것도 안 됨, 잠긴 개체·쪽 수 한도 거절, results·copies 모양, render 결과를 그림 목록 규칙으로 검사. init 이 만든 test.mjs 가 이것을 쓴다(node test.mjs).

// test.mjs — node test.mjs
import assert from 'node:assert/strict';
import { loadPlugin } from '../podolay-plugin-sdk-1.6.2/podolay-mock.mjs';   // SDK 의 자리에 맞게 (upgrade 가 새 SDK 경로로 고친다)

const p = await loadPlugin(new URL('.', import.meta.url).pathname, {
  doc: {
    pages: 2,                                        // 쪽 수 (또는 [{ master: 10 }, …]) — 모의 문서는 쪽을 가로로: 쪽 i 의 x = i × 595
    graphics: [
      { id: 1, kind: 'rectangle', frame: { x: 10, y: 10, width: 50, height: 30 }, fill: '#ff0000' },
      { id: 2, kind: 'text', frame: { x: 10, y: 60, width: 200, height: 40 }, text: '값: {{v}}' },
      { id: 3, kind: 'rectangle', frame: { x: 100, y: 10, width: 40, height: 40 }, locked: true, pluginData: { slot: 'photo' } },
    ],
  },
  selection: [1, 3],
  storage: { next: 5 },                              // 계정 저장소의 처음 값
});

await p.run('myCommand');                            // 매니페스트의 명령 (메뉴·단축키와 같다)
assert.equal(p.graphic(1).frame.x, 20);              // 플러그인이 보는 것과 같은 꼴의 개체
assert.equal(p.history.length, 1);                   // Undo 단계 — batch 는 한 단계
assert.equal(p.toasts.at(-1).kind, 'success');
손잡이하는 일
p.run(command)명령을 부른다 (API 1.4 — p.editText 로 글을 편집 중이면 헤더 메뉴·단축키처럼 text 인자, 끝나면 편집을 다시 연다)
p.menuItems(context, ids) · p.runMenu(command, at)우클릭 메뉴(API 1.2) — 그 자리에 나올 항목 · 항목 고르기(나오지 않으면 던짐, 명령은 편집기와 같은 꼴의 인자)
p.render(type, data, size)render 를 불러 그림 목록 규칙으로 검사한 항목 (p.defaults(type) = 기본 데이터)
p.tool(id, 'onDown', { x, y })custom 툴 이벤트 — 미리보기 그림(검사함)
p.inspect(id, { key: 값, … })사용자가 인스펙터 칸을 바꿈 — 칸마다 Undo 한 단계, autoSize 면 크기도, dataChange(inspector)
p.inspectorFields(id | { text, index })인스펙터에 보일 칸과 값 (글 속 개체면 hideInline 칸은 빠진다)
p.createWithTool(toolId, frame?)createGraphic 툴로 만들기 — 기본 데이터·autoSize 크기, 새 개체를 고른다 → 새 id
p.undo() · p.redo()되돌리기·다시 하기 (history 에서 빠지고 들어간다) — dataChange(undo·redo)·historyChange
p.emit(name, payload)이벤트를 손으로 보냄 (selectionChange 는 selection 권한이 있을 때만) — 보통은 저절로 온다
p.events · p.flush()플러그인에 간 이벤트 [{ name, payload }] · 쌓인 이벤트를 지금 보냄
p.fromPanel(panelId, msg)패널이 보낸 메시지 (ui.onMessage 로)
p.select(ids) · p.cancelBatch()사용자가 개체를 고름(selectionChange, 글 편집은 끝난다) · 진행 창의 취소
p.editText(id, start?, end?) · p.textSelection사용자가 글을 편집함(API 1.4 — [start, end) 선택, null = 편집 끝, 끝나면 textSelectionChange) · 편집 중인 선택 { id, start, end, text }. 모의 편집기에는 표가 없어 글 상자만
p.graphic(id) · p.graphics() · p.pages() · p.pageOf(id) · p.docData()문서 상태
p.selection · p.history · p.toasts · p.progress · p.panelsOpen · p.panelMessages · p.storage · p.license · p.logs기록

옵션: doc.styles(단락·문자 스타일 목록), doc.masters, pageLimit(요금제 쪽 수 한도), locale, fetch(네트워크 대역), echo: true(로그를 콘솔에도), dir(createHarness 로 만들 때 package.read 가 읽을 플러그인 폴더 — loadPlugin 은 그 폴더). podolay.package.read 는 플러그인 폴더에서 편집기와 같은 규칙(상대 경로·20MB·패키지 확장자)으로 읽는다.

이벤트는 편집기처럼 저절로 온다 — selectionChange(selection.set·p.select·우클릭·지운 개체·batch 가 끝나 선택이 돌아올 때, 바뀔 때만), dataChange(이 플러그인 개체의 데이터가 실제로 바뀔 때 — tx.setData 는 plugin, p.inspect 는 inspector, p.undo·p.redo), historyChange(p.undo·p.redo), documentChange { origins }(그동안 바뀐 까닭 plugin·user·history 를 모아 한 번 — 편집기가 0.2초 모으는 것과 같다). 손잡이의 동작(run·runMenu·inspect·undo…)이 끝날 때 차례로 보내고 기다리므로, 그 뒤에 바로 단언하면 된다. 동작 밖(타이머 등)에서 부른 edit 의 이벤트는 await p.flush() 로 보낸다. 처리기에서 던진 오류는 편집기처럼 p.logs 에만 남는다(p.emit 은 던진다). 매니페스트가 틀리면(live 를 글 칸에, 1.2 에서 autoSize 등) 편집기처럼 불러오지 않고 loadPlugin 이 던진다.

모의 편집기는 조판하지 않는다 — doc.textStatus·text.measure 의 줄 수·높이·넘침은 글자 수로 어림한 값이고, 그림은 그리지 않는다. 그런 것은 같은 test.mjs 를 진짜 편집기에서 돌려 확인한다: node tools.mjs test my-plugin (§8.10). 예제 number-objects·select-same-fill·catalog 의 test.mjs 를 참고한다.

패널 html 시험 — podolay-ui-stub.js: 패널은 편집기가 넣어 주는 podolayUI 로만 말하므로, 브라우저에서 패널 html 만 열면 돌지 않는다. SDK 의 podolay-ui-stub.js 를 패널보다 먼저 돌리면 같은 꼴의 podolayUI(그리고 편집기 패널의 어두운 기본 모양)가 생긴다. Playwright 로:

import { chromium } from 'playwright';
import { loadPlugin, wirePanel, PANEL_STUB } from '../podolay-plugin-sdk-1.6.2/podolay-mock.mjs';

const browser = await chromium.launch();
const page = await browser.newPage();
// ① 패널만 — 오간 메시지를 직접 본다
await page.addInitScript({ path: PANEL_STUB });
await page.goto('file://' + new URL('panel.html', import.meta.url).pathname);
await page.click('#apply');
assert.deepEqual(await page.evaluate(() => __podolayUI.sent), [{ type: 'apply', value: 3 }]);   // 패널 → main.js
await page.evaluate(() => __podolayUI.deliver({ count: 3 }));                                  // main.js → 패널 (ui.postMessage)

// ② 모의 편집기와 이어서 — 패널의 postMessage 는 p.fromPanel 로, 플러그인의 ui.postMessage 는 패널로 (goto 전에)
const p = await loadPlugin('.', { doc: { … } });
const page2 = await browser.newPage();
const wire = await wirePanel(page2, p, 'main');
await page2.goto('file://' + new URL('panel.html', import.meta.url).pathname);
await page2.click('#apply');
await wire.idle();                                                       // main.js 가 받아 처리하고 답이 패널에 닿을 때까지
assert.equal(p.history.length, 1);

__podolayUI = { sent, deliver(msg), waitFor(pred?, timeout?), clear(), listeners }. 메시지는 편집기처럼 구조화 복제로 옮긴다. 편집기 기본 모양을 빼려면 window.__podolayUINoBaseCss = true 를 먼저 넣는다. Playwright 는 플러그인 폴더에 따로 깐다(npm i -D playwright — Google Chrome 이 있으면 launch({ channel: 'chrome' })).

8.9 올리기 전·올린 뒤

  • SDK 판: node tools.mjs --version → PodoLay 플러그인 SDK 1.6.0 (API 1.6). check·dev 도 첫 줄에 판을 보이고, 개발 서버는 붙은 편집기의 API 판이 SDK 보다 새롭거나 옛것이면 알린다(새 SDK 를 받으라는 뜻). 플러그인 폴더의 podolay-plugin.d.ts 가 옛 판이면 check 가 경고한다.
  • 새 SDK 알림: dev·check·--version 이 하루에 한 번 https://podolay.com/downloads/podolay-plugin-sdk.json(최신 SDK 판·API 판·바뀐 것)을 읽어 새 판이 있으면 알린다. 네트워크가 안 되면 조용히 넘어간다. 끄기: PODOLAY_NO_UPDATE_CHECK=1.
  • SDK 언어: 한국어판(podolay-plugin-sdk-<판>.zip)과 영어판(podolay-plugin-sdk-<판>-en.zip)은 도구가 찍는 글, init 이 만드는 AGENTS.md·CLAUDE.md·뼈대, 타입 정의 설명, 예제, 안내서의 언어만 다르다. 받은 판과 다른 언어로 찍으려면 PODOLAY_SDK_LANG=en(또는 ko). 편집기가 플러그인에 주는 오류 글은 언제나 한국어다.
  • SDK 업그레이드 — 아래 §8.9.1.
  • node tools.mjs pack 이 먼저 check 를 돌리고(오류면 멈춤) 허용 확장자와 서버 한도(zip 5MB·파일 200개·풀었을 때 20MB)를 본다. 개발용 파일(AGENTS.md·CLAUDE.md·jsconfig.json·*.mjs·dev.log·test/)은 넣지 않는다. 파일은 deflate 로 압축한다(png·jpg·webp·woff2 처럼 이미 압축된 것과 줄지 않는 것은 그대로) — 5MB 한도는 압축한 zip 의 크기다. 압축하지 않으려면 --store.
  • 포털에 올리면 서버가 다시 검사하고 틀린 곳을 알려 준다. 검수 신청한 판은 관리자가 실제 편집기에서 돌려 본다.
  • 설치한 사람의 편집기에서는 로그 창이 없다 — 문제가 보고되면 같은 판을 개발 모드로 불러 같은 문서 상태에서 재현한다. 사용자에게 보여야 할 실패는 ui.toast(…, 'error') 로 알린다.

8.9.1 SDK 업그레이드 — 받을 곳·풀 곳·명령

SDK 는 판마다 폴더 이름에 판이 든 새 폴더로 풀린다(zip 이름 = 안의 폴더 이름). 옛 폴더 위에 덮어 풀지 않고 옆에 새로 푼 뒤, 새 SDK 의 tools.mjs 로 플러그인 폴더를 옮겨 간다. 플러그인 폴더의 AGENTS.md·시험의 import 에 SDK 경로가 들어 있어서, 새 SDK 의 도구로 upgrade 해야 그 경로가 새 폴더로 바뀐다.

무엇주소·이름
최신 판 알아내기https://podolay.com/downloads/podolay-plugin-sdk.json 의 sdk (그 판의 zip 주소는 zips.ko·zips.en)
한국어판 ziphttps://podolay.com/downloads/podolay-plugin-sdk-<판>.zip → 풀면 podolay-plugin-sdk-<판>/
영어판 ziphttps://podolay.com/downloads/podolay-plugin-sdk-<판>-en.zip → 풀면 podolay-plugin-sdk-<판>-en/
언제나 최신 판(판 없는 이름)https://podolay.com/downloads/podolay-plugin-sdk.zip·podolay-plugin-sdk-en.zip — 풀면 역시 판 붙은 폴더
풀 곳지금 SDK 폴더가 든 폴더(지금 SDK 폴더의 옆). 플러그인 폴더의 AGENTS.md 첫 구역에 그 경로가 적혀 있다

예: 작업 폴더 ~/Documents/podolay 에 podolay-plugin-sdk-1.6.2/ 과 my-plugin/ 이 있고 새 판이 1.6.3 일 때 — 플러그인 폴더에서:

cd ~/Documents/podolay/my-plugin
curl -s https://podolay.com/downloads/podolay-plugin-sdk.json | grep '"sdk"'      # "sdk": "1.6.3"
curl -L -o ../podolay-plugin-sdk-1.6.3.zip https://podolay.com/downloads/podolay-plugin-sdk-1.6.3.zip
unzip -q ../podolay-plugin-sdk-1.6.3.zip -d ..                                       # → ../podolay-plugin-sdk-1.6.3/
node ../podolay-plugin-sdk-1.6.3/tools.mjs upgrade . --dry-run                       # 무엇을 바꿀지 보기만
node ../podolay-plugin-sdk-1.6.3/tools.mjs upgrade .                                 # ← 반드시 새 SDK 의 tools.mjs
node ../podolay-plugin-sdk-1.6.3/tools.mjs check . && node test.mjs

Windows(PowerShell)는 Invoke-WebRequest <주소> -OutFile ..\podolay-plugin-sdk-1.6.3.zip → Expand-Archive ..\podolay-plugin-sdk-1.6.3.zip -DestinationPath ... 플러그인이 여럿이면 플러그인 폴더마다 node <새 SDK 폴더>/tools.mjs upgrade <플러그인 폴더>. 모두 옮긴 뒤 옛 SDK 폴더(podolay-plugin-sdk-1.6.2/)와 zip 은 지워도 된다.

  • 옛 도구로 upgrade 하지 않는다 — 옛 폴더의 tools.mjs upgrade 는 옛 판에 맞출 뿐이다. node <SDK 폴더>/tools.mjs --version 으로 판을 확인한다.
  • SDK 1.6.1 이하에서 올 때: 그때 zip 은 판 없는 podolay-plugin-sdk/ 로 풀렸다. 새 zip 은 그 옆에 podolay-plugin-sdk-<판>/ 을 만든다 — 위와 같이 새 폴더의 tools.mjs upgrade 를 돌리면 플러그인 폴더가 새 폴더를 가리키고, 그 뒤 podolay-plugin-sdk/ 는 지워도 된다.
  • 네트워크를 쓸 수 없는 AI 에이전트는 사람에게 "위 zip 주소를 받아 <풀 곳> 에 풀어 달라" 고 한 뒤 3단계부터 한다.

upgrade 는 podolay-plugin.d.ts 를 새 판으로 바꾸고, AGENTS.md 의 SDK 구역(<!-- podolay-sdk:begin --> … end, SDK 경로·업그레이드 명령 포함)만 다시 쓰고 (그 아래 '이 플러그인 메모' 는 그대로 — 표식이 없는 SDK 1.1.x 의 AGENTS.md 는 AGENTS.old.md 로 두고 새로 쓴다), 없는 CLAUDE.md·jsconfig.json 을 만들고, *.mjs 시험의 podolay-mock.mjs·podolay-remote.mjs import 를 새 SDK 경로로 고친 뒤, 옛 판 뒤에 바뀐 것을 보여 준다. 플러그인 코드와 매니페스트는 건드리지 않는다. apiVersion 은 그대로 두어도 된다 — 1.1~1.5 플러그인은 1.6 편집기에서 그대로 돈다. 새 API 를 쓸 때만 올린다(올리면 그 API 를 모르는 옛 편집기는 불러오지 않는다). 끝나면 check → node test.mjs → tools.mjs test.

8.10 샌드박스 편집기 — 서버 없이 진짜 편집기에서 시험하고 터미널에서 조종하기

node tools.mjs dev my-plugin --open        # 개발 서버 + 브라우저에 샌드박스 편집기 (브라우저 없이: --headless)

샌드박스 편집기는 로그인 없이 열리는 편집기다(…/editor/vX.Y.Z/index.html?sandbox=1&pluginDev=…).

  • 문서: 기본 설정의 새 문서다. 포털의 '새 문서' 기본값과 같다(A4·여백 12.7mm·3쪽, 문서 언어 = 화면 언어).
  • 서버에 닿지 않는다: 저장·자동 저장·PDF·올리기가 없고, 서버의 /api 는 읽지도 쓰지도 않는다. 같은 브라우저에 로그인해 있어도 그 계정의 토큰을 쓰지 않는다.
  • 플러그인: 설치된 플러그인은 불러오지 않고, 개발 중 플러그인만 불러온다. 권한 동의 창은 없다(개발 모드와 같다). assets.upload 의 그림은 그 창의 메모리에만 있다.
  • 탭을 닫으면 문서는 사라진다. CMYK 색은 화면 근사식으로 보인다(색 변환표는 서버의 것이라).

dev 를 띄운 채 다른 터미널에서 편집기를 조종한다. 결과는 터미널로 돌아오고, 실패하면 종료 코드 1 이다.

명령하는 일
node tools.mjs status편집기·API 판, 불러온 플러그인(상태·명령), 문서
node tools.mjs run <명령> [--arg JSON]플러그인 명령 실행 — 끝날 때까지(일괄 작업 포함) 기다리고 그 사이 로그·알림·Undo 단계를 찍는다
node tools.mjs panel <패널> '<JSON>' · emit <이벤트> [JSON]패널이 보낸 메시지 · 편집기 이벤트를 플러그인에
node tools.mjs state [-o f.json]문서 상태 — 개체(플러그인이 보는 꼴)·선택·저장소·Undo 단계·알림·로그·진행·패널 메시지
node tools.mjs shot [쪽] [--scale 1.5] [-o p.png]쪽 그림 PNG(재단선 안, 안내선 없이) — AI 에이전트는 그림 파일을 읽어 눈으로 확인한다
node tools.mjs shot [쪽] --ui [-o p.png]위 그림에 고른 개체의 선택 테두리·손잡이를 덧그리고, 손잡이 자리·인스펙터 값을 p.ui.json 에
node tools.mjs raw [-o doc.json]문서 직렬화 JSON — reset --fixture 로 다시 쓸 수 있다
node tools.mjs select <id…> · undo · redo · reload선택 · 되돌리기 · 다시 하기 · 플러그인 다시 불러오기
node tools.mjs reset [옵션]새 시험 문서 (아래)
node tools.mjs test my-plugin폴더의 test.mjs 를 진짜 편집기에서 돌린다(모의 편집기 대신)

reset 의 옵션:

  • 판형·쪽: --size a4|a5|letter|6x9|<가로>x<세로>[pt|mm] · --pages n · --mode double(펼침면)
  • 언어·쓰기 방향: --lang ko|en|ja|zh-Hans|… · --vertical(세로쓰기)
  • 주 텍스트 프레임: --primary
  • 시험 문서: --fixture f.json(개발 서버 폴더 안의 문서 JSON) · --doc spec.json({ graphics, selection, storage } — 모의 편집기의 doc.graphics 와 같은 꼴. 그룹 kind: 'group'(children)·표 kind: 'table'(table)·도형의 image 도 — 편집기 2.14.13 부터. 문서의 tableStyles·표의 footerRows·tableStyle·cellStyles 는 편집기 2.16.3 부터, 칸 스타일에 fill(색)을 주면 그 값이 칸에 들어간다 — 모의 편집기는 이름만)
  • 실제 레이아웃으로 시험하기 (편집기 2.17.2 부터) — 계정의 레이아웃을 pluginDev 로 연 탭의 플러그인 메뉴 › 시험 문서로 내보내기 가 지금 레이아웃을 <제목>.fixture.json 으로 받는다. 플러그인 폴더에 두고 샌드박스에서 reset --fixture <제목>.fixture.json — 실제 레이아웃을 고치지 않고 그대로 자동 시험한다(그림은 서버 주소라 샌드박스에서 안 보일 수 있다).
  • 실제 레이아웃 읽기 원격 조종 — 계정의 레이아웃 주소에 pluginDev=… 와 함께 &pluginRemote=1 을 붙이면 그 탭이 status·state·raw·shot 만 받는다(쓰기 명령은 거절). 예제 레이아웃이 제대로 되는지 터미널에서 확인할 때. 붙이지 않으면 실제 탭은 원격 조종에 붙지 않는다(샌드박스 시험을 가로채지 않게).
  • 계정 레이아웃 탭은 처음 불러온 플러그인 id 만 받는다 — 개발 서버에 다른 플러그인을 띄우면 그 탭은 불러오지 않고 알린다(탭을 다시 연다).

dev 에도 같은 옵션을 주면 처음 열 때 그 문서가 된다. 편집기는 기본으로 운영(podolay.com)의 기본 판을 연다. 다른 판은 --editor vX.Y.Z | local | <주소> 로 고른다.

같은 시험을 양쪽에서: node test.mjs 는 모의 편집기에서, node tools.mjs test . 은 진짜 편집기에서 돈다. 후자는 환경 변수 PODOLAY_EDITOR 를 넣는다. 그러면 모의 편집기의 loadPlugin 이 podolay-remote.mjs 의 손잡이를 돌려준다.

  • 손잡이: 꼴이 같다. run·runMenu·menuItems·select·fromPanel·emit·render·defaults·undo·redo·graphic·graphics·pages·selection·history·toasts·progress·panelsOpen·panelMessages·storage. 진짜 편집기에만 있는 것은 shot(page, { ui })(PNG Buffer, ui 면 손잡이 자리·인스펙터 값도)·raw()·reset(opts) 다. 모의 편집기에만 있는 것은 inspect·inspectorFields·createWithTool·events·flush 다(진짜 편집기의 인스펙터·툴은 사람 손으로).
  • id 와 좌표: 시험이 적은 id(1, 2, 3…)와 모의 편집기의 좌표(쪽 i 는 x = i × 쪽 폭)는 손잡이가 편집기의 id·스프레드 좌표로 바꿔 보내고, 받을 때 되돌린다. 편집기의 진짜 좌표는 graphic(id).editorFrame 이다. 플러그인이 새로 만든 개체는 편집기의 id 그대로다.
  • 조판이 걸린 단언: 줄 수·넘침처럼 조판에 기대는 단언은 진짜 편집기에서만 맞는다. 모의 편집기는 어림값이다.
// Node 에서 직접 — dev --open 을 띄운 뒤
import { connectEditor } from '../podolay-plugin-sdk-1.6.2/podolay-remote.mjs';
const p = await connectEditor({ doc: { pages: 2, graphics: [{ kind: 'text', frame: { x: 40, y: 40, width: 300, height: 80 }, text: '{{title}}' }] } });
await p.run('fill');
writeFileSync('page0.png', (await p.shot(0)).png);

알아 둘 것:

  • 탭: 마지막에 붙은 편집기 탭 하나만 명령을 받는다. 새로고침하면 그 탭이 다시 붙는다. 밀려난 탭은 기다리며 몇 초마다 다시 묻다가, 명령을 받던 탭이 닫히면(3초 동안 묻지 않으면) 스스로 다시 붙는다 — 그 사이에 넣은 명령도 그 탭이 받는다.
  • shot --ui: 손잡이는 편집기 화면과 같은 자리(8방향 + 위의 회전 손잡이, 선 같은 끝점 손잡이 그래픽은 그 점들)에 개체의 회전·기울이기를 걸어 그리고, 크기는 화면 px 그대로(8px)다. p.ui.json 의 자리는 쪽 원점 기준 pt, 인스펙터 값은 고른 개체가 하나인 플러그인 개체일 때(글 속 개체면 hideInline 칸 빼고). 인스펙터 화면 자체를 찍지는 않는다 — 칸의 모양은 사람이 본다.
  • 보안: 명령은 정해 둔 것뿐이다. 편집기 페이지에서 임의 코드를 돌리지 않는다. 웹 페이지가 개발 서버를 거쳐 편집기를 조종하지 못하게, 명령 넣기는 Node(터미널)에서만 된다.
  • --headless: 플러그인 폴더나 지금 폴더에 Playwright 가 있어야 한다(npm i -D playwright). Google Chrome 이 깔려 있으면 브라우저를 따로 받지 않아도 된다.
  • https 편집기 → http://localhost: Chrome 은 이 조합을 허용한다(로컬 네트워크 접근을 물으면 허용). 막히는 브라우저면 Chrome 으로 연다.

9. 검수 기준 (요약)

  • 매니페스트 권한이 하는 일에 맞는가 (쓰지 않는 권한을 달지 않는다).
  • 사용자 문서를 바꾸는 일은 사용자가 시킨 때만 (명령·툴·인스펙터) 한다. 열자마자 문서를 고치지 않는다.
  • render 가 순수하고 빠른가 (같은 입력 → 같은 그림, 수십 ms 안).
  • 이름·설명·패널 글이 실제 기능과 맞는가. 다른 상표를 흉내 내지 않는가.
  • 네트워크 호스트가 설명한 기능에 꼭 필요한가, 무엇을 보내는지 privacyUrl 에 적혀 있는가. 판 사이 호스트가 늘었으면 그 까닭.

10. 유료 플러그인과 판매

판매자는 PodoLay(주식회사 포도)입니다. 유료 플러그인은 PodoLay 가 편집기 안의 플러그인 목록에서 팝니다. 결제·영수증·세금은 판매 대행사 Paddle(Merchant of Record)이 처리하고, 고객의 구매 계약·환불·지원은 PodoLay 가 맡습니다. 조건은 이용약관 제6조의2와 환불 정책에 있습니다.

  • 지금 파는 것 — PodoLay 가 직접 만든 플러그인만 팝니다.
  • 구매한 사람이 받는 것 — 1회 결제, 그 계정에서 기간 제한 없이 쓰는 라이선스, 이후 업데이트 포함. 요금제와 관계없이 쓸 수 있고, 결제일부터 14일 안에는 이유 없이 전액 환불됩니다(환불하면 라이선스 회수).
  • 가격 — 정해진 단계($9·19·29·39·49·79·99·149·199·299·499·749·999) 중 하나. 체험 기간을 둘 수 있습니다(첫 설치일부터).

10.1 개발자 플러그인 판매 — 준비 중

다른 개발자가 만든 플러그인의 판매는 결제 대행사의 확인을 받은 뒤 엽니다. 그때까지는 무료 플러그인만 올릴 수 있습니다. 열릴 때의 구조는 다음과 같습니다.

  1. 개발자는 PodoLay 와 배포·재판매 계약을 맺고, 플러그인을 배포·판매할 권리를 PodoLay 에 허락합니다.
  2. PodoLay 가 판매자로서 자기 결제로 팝니다. 개발자는 대금을 직접 받지 않고, 고객과 따로 계약하거나 환불·지원을 맡지 않습니다.
  3. 모든 판은 공개 전에 PodoLay 가 검수하고(9절), 플러그인은 편집기 안의 격리된 환경에서 밝힌 권한 안에서만 돕니다. PodoLay 는 언제든 게시를 내리거나 라이선스를 회수할 수 있습니다.
  4. 가격은 위 단계 중 하나를 개발자가 고르고, 체험 기간도 개발자가 정합니다(0일 가능).
  5. PodoLay 는 계약에 따라 매달 순매출(세금·환불 제외)의 85% 를 사용료로 개발자에게 지급합니다. 결제 수수료는 PodoLay 가 부담하고, 최소 지급액은 $100 입니다. 환불된 판매는 사용료에서 빠집니다.