powerpointem-all
Version:
PowerPoint'em All: Export selected frames from Figma to PowerPoint with local agent server.
684 lines (641 loc) • 31.5 kB
JavaScript
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const os = require('os');
const path = require('path');
const fs = require('fs');
const { execFile, execFileSync } = require('child_process');
const PptxGenJS = require('pptxgenjs');
const APP_VERSION = '1.0.0';
const PORT = process.env.PORT || 54123;
const BIND_HOST = '127.0.0.1';
const AUTH_TOKEN = process.env.PPT_AGENT_TOKEN || '';
const app = express();
app.use(cors({ origin: (origin, cb) => cb(null, true) }));
app.disable('x-powered-by');
app.use(express.json({ limit: '50mb' }));
// 실험 기능: SVG를 AppleScript+VBA로 벡터 삽입 (기본 비활성화)
const ENABLE_SVG_VECTOR_INSERT = false;
// 회전 앵커 모드: 'top-left' | 'center' | 'top-center'
const ANCHOR_MODE = 'center';
// 사용자 정의 폰트 매핑 로드
let USER_FONTMAP = { aliases: {}, styles: {}, explicit: [] };
try {
const mappath = path.join(__dirname, 'fontmap.json');
if (fs.existsSync(mappath)) {
USER_FONTMAP = JSON.parse(fs.readFileSync(mappath, 'utf8')) || USER_FONTMAP;
}
} catch {}
function degToRad(deg) { return (Number(deg) || 0) * Math.PI / 180; }
function computeXYFromAnchor(n, wIn, hIn, slideWInch, slideHInch) {
// inches 기반 cx,cy
const cxIn = (typeof n.cx === 'number' ? n.cx : (n.x || 0) + (n.width || 0) / 2) / 96;
const cyIn = (typeof n.cy === 'number' ? n.cy : (n.y || 0) + (n.height || 0) / 2) / 96;
const theta = degToRad(Number(n.rotation) || 0);
const cos = Math.cos(theta), sin = Math.sin(theta);
if (ANCHOR_MODE === 'top-left') {
// 좌상단 기준: 전달된 x,y 사용 (안정 경로)
let x = Math.max(0, Math.min((Number(n.x) || 0) / 96, slideWInch - 0.01));
let y = Math.max(0, Math.min((Number(n.y) || 0) / 96, slideHInch - 0.01));
return { x, y };
}
// center 또는 top-center: 앵커 오프셋을 회전에 맞춰 보정 후, PPT의 top-left로 변환
let vx = 0, vy = 0;
if (ANCHOR_MODE === 'top-center') {
vx = 0; vy = -hIn / 2; // 상단 중앙
} else {
vx = 0; vy = 0; // center
}
const vxr = vx * cos - vy * sin;
const vyr = vx * sin + vy * cos;
let x = cxIn + vxr - wIn / 2;
let y = cyIn + vyr - hIn / 2;
if (!Number.isFinite(x)) x = 0;
if (!Number.isFinite(y)) y = 0;
x = Math.max(0, Math.min(x, slideWInch - 0.01));
y = Math.max(0, Math.min(y, slideHInch - 0.01));
return { x, y };
}
// Multer with limits
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
files: 1,
},
});
function requireAuth(req, res, next) {
if (!AUTH_TOKEN) return next(); // token not set -> no auth required
const header = req.get('authorization') || '';
const token = header.toLowerCase().startsWith('bearer ')
? header.slice(7)
: '';
if (token && token === AUTH_TOKEN) return next();
return res.status(401).json({ error: 'unauthorized' });
}
// --- Font resolution (macOS) -------------------------------------------------
let INSTALLED_FONT_SET = new Set();
function loadInstalledFontsMac() {
try {
const out = execFileSync('system_profiler', ['SPFontsDataType', '-json'], { encoding: 'utf8' });
const json = JSON.parse(out);
const items = json && json.SPFontsDataType ? json.SPFontsDataType : [];
const names = new Set();
items.forEach((entry) => {
const fam = (entry.family || '').toString();
const full = (entry.fullName || '').toString();
const post = (entry.postScriptName || '').toString();
[fam, full, post]
.filter(Boolean)
.forEach((n) => names.add(n.trim().toLowerCase()));
});
INSTALLED_FONT_SET = names;
} catch (e) {
INSTALLED_FONT_SET = new Set();
}
}
function resolveFontFace(preferredList) {
for (const name of preferredList) {
if (INSTALLED_FONT_SET.has(name.trim().toLowerCase())) return name;
}
// macOS 기본 한글 폴백 후보
const fallbacks = ['Apple SD Gothic Neo', 'PingFang TC', 'Helvetica Neue', 'Arial'];
for (const name of fallbacks) {
if (INSTALLED_FONT_SET.has(name.trim().toLowerCase())) return name;
}
return 'Arial';
}
loadInstalledFontsMac();
// 선호 폰트 목록과 현재 선택된 폰트(폴백 포함)
const PREFERRED_FONTS = [
'나눔스퀘어 네오', '나눔스퀘어 네오 OTF', '나눔스퀘어OTF',
'NanumSquare Neo', 'NanumSquare Neo OTF', 'NanumSquareNeoOTF', 'NanumSquareOTF',
'Noto Sans KR', 'Noto Sans CJK KR', 'Apple SD Gothic Neo', 'Pretendard', 'Inter', 'Arial'
];
const FORCED_FONT = resolveFontFace(PREFERRED_FONTS);
const CURRENT_FONT = FORCED_FONT || 'Arial';
// 폰트 별칭 매핑 및 런 폰트 해결기
const FONT_ALIASES = Object.assign({
'nanumsquare neo': [
'나눔스퀘어 네오', '나눔스퀘어 네오 OTF', '나눔스퀘어OTF',
'NanumSquare Neo', 'NanumSquare Neo OTF', 'NanumSquareNeoOTF', 'NanumSquareOTF',
'Noto Sans KR', 'Noto Sans CJK KR', 'Apple SD Gothic Neo', 'Pretendard', 'Inter', 'Arial'
]
}, (USER_FONTMAP.aliases || {}));
function resolveFontForName(name) {
const raw = String(name || '').trim();
if (!raw) return CURRENT_FONT;
const lower = raw.toLowerCase();
const aliasList = FONT_ALIASES[lower];
if (Array.isArray(aliasList) && aliasList.length > 0) {
// 별칭이 있으면 첫 후보를 사용 (예: Apple SD Gothic -> Apple SD Gothic Neo)
return aliasList[0];
}
// 설치 폰트 탐지가 비어있어도 원본 폰트명을 그대로 전달해 PPT가 매칭하도록 한다.
return raw;
}
// 스타일(굵기) 변형을 설치 폰트명으로 해석 (Light/Regular/Medium/SemiBold/Bold/ExtraBold/Black/Heavy 등)
const STYLE_CANON = Object.assign({
light: ['Light', 'ExtraLight', 'UltraLight', 'Thin'],
regular: ['Regular', 'Normal', 'Book'],
medium: ['Medium'],
semibold: ['SemiBold', 'DemiBold'],
bold: ['Bold'],
extrabold: ['ExtraBold', 'Heavy', 'Black'],
heavy: ['Heavy', 'Black'],
}, (() => {
const s = USER_FONTMAP.styles || {}; const out = {};
Object.keys(s).forEach((k) => { out[k.toLowerCase()] = s[k]; });
return out;
})());
function resolveFontFaceWithStyle(family, style) {
const base = resolveFontForName(family || CURRENT_FONT);
const s = String(style || '').trim();
if (!s) return base;
const lower = s.toLowerCase();
// 수집 가능한 후보 스타일명 목록 구성
const candidatesStyle = [s];
Object.entries(STYLE_CANON).forEach(([k, arr]) => {
if (lower.includes(k)) candidatesStyle.push(...arr);
});
// 일반적인 케이스도 추가(정확 매칭)
const common = ['Light', 'Regular', 'Medium', 'SemiBold', 'DemiBold', 'Bold', 'ExtraBold', 'Black', 'Heavy', 'Book', 'Normal'];
common.forEach((c) => { if (!candidatesStyle.includes(c)) candidatesStyle.push(c); });
// 명시적 매핑 우선
try {
const explicit = Array.isArray(USER_FONTMAP.explicit) ? USER_FONTMAP.explicit : [];
for (const rule of explicit) {
const condFam = String(rule?.if?.family || '').toLowerCase();
const condSty = String(rule?.if?.style || '').toLowerCase();
if (condFam && base.toLowerCase().includes(condFam) && (!condSty || lower.includes(condSty))) {
const use = String(rule.use || '').trim();
if (use) return use;
}
}
} catch {}
// family + style 조합으로 설치 폰트 세트에서 탐색 (fullName/postScriptName 가능성 모두 커버)
for (const st of candidatesStyle) {
const withSpace = `${base} ${st}`.toLowerCase();
const withDash = `${base}-${st}`.toLowerCase();
if (INSTALLED_FONT_SET.has(withSpace) || INSTALLED_FONT_SET.has(withDash)) {
return `${base} ${st}`;
}
}
// 매칭 실패 시에도 조합한 변형명을 우선 반환해 PPT의 로컬 매칭에 맡긴다
// 예: "Family ExtraBold" 또는 "Family-ExtraBold"
const preferred = `${base} ${candidatesStyle[0]}`.trim();
return preferred;
}
function styleFromWeight(weight) {
const w = Number(weight) || 400;
if (w <= 150) return 'Thin';
if (w <= 250) return 'ExtraLight';
if (w <= 350) return 'Light';
if (w <= 450) return 'Regular';
if (w <= 550) return 'Medium';
if (w <= 650) return 'SemiBold';
if (w <= 750) return 'Bold';
if (w <= 850) return 'ExtraBold';
return 'Black';
}
// macOS 기본 다운로드 폴더(없으면 tmp로 폴백)
function getDownloadsDir() {
try {
const downloads = path.join(os.homedir(), 'Downloads');
if (fs.existsSync(downloads) && fs.statSync(downloads).isDirectory()) return downloads;
} catch {}
return os.tmpdir();
}
app.get('/health', (req, res) => {
res.json({ status: 'ok', platform: process.platform });
});
app.get('/v1/capabilities', (req, res) => {
res.json({
version: APP_VERSION,
platform: process.platform,
endpoints: ['/v1/open', '/v1/session/apply'],
maxUploadMB: 50,
auth: AUTH_TOKEN ? 'required' : 'optional',
fonts: {
using: CURRENT_FONT,
forced: FORCED_FONT,
preferred: PREFERRED_FONTS,
installed: INSTALLED_FONT_SET.has(FORCED_FONT.trim().toLowerCase()),
installedCount: Array.isArray([...INSTALLED_FONT_SET]) ? INSTALLED_FONT_SET.size : 0,
},
});
});
app.post('/upload', requireAuth, upload.single('file'), handleOpen); // backward compatibility
app.post('/v1/open', requireAuth, upload.single('file'), handleOpen);
// New: Apply a live session of frames/nodes to PowerPoint (layer-level rendering)
app.post('/v1/session/apply', requireAuth, async (req, res) => {
try {
if (process.platform !== 'darwin') {
return res.status(400).json({ error: 'This agent only supports macOS.' });
}
const frames = Array.isArray(req.body?.frames) ? req.body.frames : null;
if (!frames || frames.length === 0) {
return res.status(400).json({ error: 'No frames provided' });
}
// Verbose request log
try {
const firstInfo = frames[0] ? { width: frames[0].width, height: frames[0].height, nodes: Array.isArray(frames[0].nodes) ? frames[0].nodes.length : 0 } : null;
console.log('[agent] /v1/session/apply', { frames: frames.length, first: firstInfo });
} catch {}
// Convert pixels to points (PowerPoint uses points; 72 pt = 1 inch, 96 px = 1 inch)
const pxToPt = (px) => Math.round((Number(px) || 0) * 72 / 96);
const sanitizeHex = (hex) => {
const s = String(hex || '').replace(/^#/, '').toUpperCase();
return (/^[0-9A-F]{6}$/.test(s) ? s : '000000');
};
const safeNum = (n, def = 0) => {
const v = Number(n);
return Number.isFinite(v) ? v : def;
};
// Prepare temporary image files for image nodes
const tmpFiles = [];
const makeTmpPngFromDataUrl = (dataUrl, namePrefix) => {
const match = /^data:(.*?);base64,(.+)$/.exec(dataUrl || '');
if (!match) return null;
const mime = match[1] || 'image/png';
if (!/png|jpeg|jpg/.test(mime)) {
// Only allow PNG/JPEG
return null;
}
const ext = /jpeg|jpg/.test(mime) ? '.jpg' : '.png';
const buf = Buffer.from(match[2], 'base64');
const p = path.join(os.tmpdir(), `${namePrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
fs.writeFileSync(p, buf);
tmpFiles.push(p);
return p;
};
const makeTmpSvgFromDataUrl = (dataUrl, namePrefix) => {
const match = /^data:(.*?);base64,(.+)$/.exec(dataUrl || '');
if (!match) return null;
const mime = match[1] || 'image/svg+xml';
if (!/svg\+xml/.test(mime)) return null;
const buf = Buffer.from(match[2], 'base64');
const p = path.join(os.tmpdir(), `${namePrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.svg`);
fs.writeFileSync(p, buf);
tmpFiles.push(p);
return p;
};
const svgQueue = [];
/*
// Build AppleScript that runs VBA inside PowerPoint for reliability (disabled: migrated to PptxGenJS)
// 필수: forceSize가 없으면 첫 프레임이 1920x1080이더라도 고정 적용
const first = frames[0];
const forceSize = req.body && req.body.forceSize ? req.body.forceSize : { width: first.width, height: first.height };
const slideWidthPt = forceSize ? pxToPt(forceSize.width) : pxToPt(first.width);
const slideHeightPt = forceSize ? pxToPt(forceSize.height) : pxToPt(first.height);
const vbaLines = [];
vbaLines.push(`ActiveWindow.ViewType = ppViewNormal`);
vbaLines.push(`If Presentations.Count = 0 Then Presentations.Add`);
vbaLines.push(`With ActivePresentation.PageSetup`);
vbaLines.push(` .SlideWidth = ${slideWidthPt}`);
vbaLines.push(` .SlideHeight = ${slideHeightPt}`);
vbaLines.push(`End With`);
frames.forEach((frame, frameIndex) => {
vbaLines.push(`Set s = ActivePresentation.Slides.Add(ActivePresentation.Slides.Count + 1, ppLayoutBlank)`);
vbaLines.push(`s.Select`);
// Process nodes
(frame.nodes || []).forEach((n, idx) => {
const L = pxToPt(n.x);
const T = pxToPt(n.y);
const W = pxToPt(n.width);
const H = pxToPt(n.height);
if (n.type === 'shape') {
const shapeConst = n.shape === 'OVAL' ? 'msoShapeOval' : 'msoShapeRectangle';
vbaLines.push(`Set shp = s.Shapes.AddShape(${shapeConst}, ${L}, ${T}, ${W}, ${H})`);
// Color is hex like RRGGBB
const color = String(n.color || 'C0C0C0');
const r = parseInt(color.slice(0, 2), 16) || 192;
const g = parseInt(color.slice(2, 4), 16) || 192;
const b = parseInt(color.slice(4, 6), 16) || 192;
vbaLines.push(`shp.Fill.ForeColor.RGB = RGB(${r}, ${g}, ${b})`);
} else if (n.type === 'text') {
// Escape quotes for VBA and normalize newlines to concatenation with vbCrLf
const textWhole = String(n.text || '')
.replace(/"/g, '""')
.replace(/\r?\n/g, '" & vbCrLf & "');
const defaultFontSize = Math.max(1, Math.floor(Number(n.fontSize) || 14));
const color = String(n.color || '000000');
const r = parseInt(color.slice(0, 2), 16) || 0;
const g = parseInt(color.slice(2, 4), 16) || 0;
const b = parseInt(color.slice(4, 6), 16) || 0;
vbaLines.push(`Set tb = s.Shapes.AddTextbox(msoTextOrientationHorizontal, ${L}, ${T}, ${W}, ${H})`);
vbaLines.push(`tb.TextFrame.TextRange.Text = "${textWhole}"`);
// 기본 폰트/색/간격 적용
vbaLines.push(`With tb.TextFrame.TextRange.Font`);
vbaLines.push(` .Size = ${defaultFontSize}`);
vbaLines.push(` .Color.RGB = RGB(${r}, ${g}, ${b})`);
vbaLines.push(`End With`);
// 행간 규칙
if (n.paragraph && n.paragraph.line) {
const ln = n.paragraph.line;
if (ln.rule === 'auto') {
vbaLines.push(`tb.TextFrame.TextRange.ParagraphFormat.SpaceWithin = 1`);
} else if (ln.rule === 'multiple' && typeof ln.value === 'number') {
vbaLines.push(`tb.TextFrame.TextRange.ParagraphFormat.SpaceWithin = ${Math.max(0.5, Math.min(3, ln.value / 100))}`);
} else if (ln.rule === 'exact' && typeof ln.value === 'number') {
// 정확한 행간(pt) → PowerPoint는 Multiple로 근사
vbaLines.push(`tb.TextFrame.TextRange.ParagraphFormat.SpaceWithin = ${Math.max(0.5, Math.min(3, ln.value / defaultFontSize))}`);
}
}
// 자간(문자 간격)을 pt로 받은 경우: PowerPoint에 Exact 매핑이 없어 확대/축소로 근사 어렵기 때문에 생략 또는 시뮬레이션(미지원)
// 별도 폰트 축척으로 근사하려면 추가 VBA가 필요
// 세로 정렬
const valign = String(n.valign || '').toUpperCase();
if (valign === 'CENTER') {
vbaLines.push(`tb.TextFrame.VerticalAnchor = msoAnchorMiddle`);
} else if (valign === 'BOTTOM') {
vbaLines.push(`tb.TextFrame.VerticalAnchor = msoAnchorBottom`);
} else {
vbaLines.push(`tb.TextFrame.VerticalAnchor = msoAnchorTop`);
}
// 가로 정렬
const align = String(n.align || '').toUpperCase();
if (align === 'CENTER') {
vbaLines.push(`tb.TextFrame.TextRange.ParagraphFormat.Alignment = ppAlignCenter`);
} else if (align === 'RIGHT') {
vbaLines.push(`tb.TextFrame.TextRange.ParagraphFormat.Alignment = ppAlignRight`);
} else if (align === 'JUSTIFIED') {
vbaLines.push(`tb.TextFrame.TextRange.ParagraphFormat.Alignment = ppAlignJustify`);
} else {
vbaLines.push(`tb.TextFrame.TextRange.ParagraphFormat.Alignment = ppAlignLeft`);
}
// 런(run) 스타일 적용
if (Array.isArray(n.runs) && n.runs.length > 0) {
n.runs.forEach((run, rIdx) => {
const start = Math.max(0, Number(run.start) || 0) + 1; // VBA는 1-based 인덱스
const len = Math.max(0, (Number(run.end) || 0) - (Number(run.start) || 0));
const fname = (run.fontFamily ? String(run.fontFamily) : '').replace(/"/g, '""');
const fsize = Math.max(1, Math.floor(Number(run.fontSize) || defaultFontSize));
const frgb = String(run.color || '').toUpperCase();
const rr = parseInt(frgb.slice(0, 2), 16);
const rg = parseInt(frgb.slice(2, 4), 16);
const rb = parseInt(frgb.slice(4, 6), 16);
vbaLines.push(`Set rng = tb.TextFrame.TextRange.Characters(${start}, ${len})`);
vbaLines.push(`With rng.Font`);
if (fname) vbaLines.push(` .Name = "${fname}"`);
vbaLines.push(` .Size = ${fsize}`);
if (!isNaN(rr) && !isNaN(rg) && !isNaN(rb)) {
vbaLines.push(` .Color.RGB = RGB(${rr}, ${rg}, ${rb})`);
}
vbaLines.push(` .Bold = ${run.bold ? 'msoTrue' : 'msoFalse'}`);
vbaLines.push(` .Italic = ${run.italic ? 'msoTrue' : 'msoFalse'}`);
vbaLines.push(` .Underline = ${run.underline ? 'msoTrue' : 'msoFalse'}`);
vbaLines.push(` .Strikethrough = ${run.strike ? 'msoTrue' : 'msoFalse'}`);
vbaLines.push(`End With`);
});
}
} else if (n.type === 'image' && n.imageDataUrl) {
const filePath = makeTmpPngFromDataUrl(n.imageDataUrl, `figma-img-${frameIndex}-${idx}`);
if (filePath) {
const fileWinPath = filePath.replace(/\\/g, '/');
vbaLines.push(`s.Shapes.AddPicture("${fileWinPath.replace(/"/g, '""')}", msoFalse, msoTrue, ${L}, ${T}, ${W}, ${H})`);
}
}
});
});
*/
// Instead of VBA, generate a PPTX with PptxGenJS and open it
const forceSize = req.body && req.body.forceSize;
const first = frames[0];
const pres = new PptxGenJS();
const toInch = (px) => (Number(px) || 0) / 96;
const clampInch = (inch) => Math.max(0.01, Number(inch) || 0.01);
// Do not force a global theme font; allow per-run/per-text fontFace to apply
// pres.theme = {
// headFontFace: CURRENT_FONT,
// bodyFontFace: CURRENT_FONT,
// };
const slideWInch = clampInch(toInch(forceSize ? forceSize.width : first.width));
const slideHInch = clampInch(toInch(forceSize ? forceSize.height : first.height));
pres.defineLayout({ name: 'FIGMA_LAYOUT', width: slideWInch, height: slideHInch });
pres.layout = 'FIGMA_LAYOUT';
console.log('[agent] layout', { widthInch: slideWInch, heightInch: slideHInch });
frames.forEach((frame, frameIdx) => {
const slide = pres.addSlide();
// 프레임 배경색 적용(솔리드만)
if (frame.backgroundColor) {
slide.background = { color: String(frame.backgroundColor).replace(/^#/, '') };
console.log('[agent] slide background', { frameIdx, color: frame.backgroundColor });
}
(frame.nodes || []).forEach((n) => {
const w = clampInch(toInch(n.width || 0));
const h = clampInch(toInch(n.height || 0));
const { x, y } = computeXYFromAnchor(n, w, h, slideWInch, slideHInch);
const rotateVal = Number.isFinite(Number(n.rotation)) ? Number(n.rotation) : 0;
// 경계 체크(슬라이드 크기 넘어가면 로그)
if (x + w > slideWInch + 0.001 || y + h > slideHInch + 0.001) {
console.warn('[agent] WARN object exceeds slide bounds', { frameIdx, type: n.type, xIn: x, yIn: y, wIn: w, hIn: h, slideWIn: slideWInch, slideHIn: slideHInch });
}
const baseLog = {
frameIdx,
type: n.type,
xPx: n.x, yPx: n.y, wPx: n.width, hPx: n.height, cxPx: n.cx, cyPx: n.cy,
xIn: x, yIn: y, wIn: w, hIn: h,
rotate: rotateVal,
anchor: ANCHOR_MODE,
};
if (n.type === 'shape') {
const fillHex = sanitizeHex(n.color ? String(n.color) : 'C0C0C0');
const shapeType = n.shape === 'OVAL' ? pres.ShapeType.ellipse : pres.ShapeType.rect;
slide.addShape(shapeType, { x, y, w, h, fill: { color: fillHex }, rotate: rotateVal });
console.log('[agent] addShape', { ...baseLog, map: n.shape, fill: fillHex });
} else if (n.type === 'image' && n.imageDataUrl) {
// 누적 회전 표현: Figma 내보낸 비트맵+PPT 회전
// NOTE: Figma exportAsync PNG에는 회전이 픽셀에 이미 반영됨 → PPT에서는 회전 0
slide.addImage({ data: n.imageDataUrl, x, y, w, h, rotate: 0 });
console.log('[agent] addImage', { ...baseLog, mime: 'image/png|jpeg (dataUrl)' });
} else if (n.type === 'svg' && n.svgDataUrl) {
if (ENABLE_SVG_VECTOR_INSERT) {
// 벡터 유지 경로 (실험): 파일로 저장 후, PPT에서 VBA로 삽입
const filePath = makeTmpSvgFromDataUrl(n.svgDataUrl, `figma-svg-${frameIdx}`);
if (filePath) {
svgQueue.push({
slide: frameIdx + 1,
path: filePath,
L: Math.max(1, pxToPt((n.x || 0))),
T: Math.max(1, pxToPt((n.y || 0))),
W: Math.max(1, pxToPt((n.width || 1))),
H: Math.max(1, pxToPt((n.height || 1))),
rotate: rotateVal,
});
console.log('[agent] queueSVG', { ...baseLog, filePath, slide: frameIdx + 1 });
}
} else {
// 기본: 래스터 이미지로 삽입(안정 경로)
// Figma에서 만든 래스터 데이터는 회전 포함 → PPT에서는 회전 0
slide.addImage({ data: n.svgDataUrl, x, y, w, h, rotate: 0 });
console.log('[agent] addSVG(rasterized)', { ...baseLog, mime: 'image/svg+xml as image' });
}
} else if (n.type === 'text') {
// PowerPoint 텍스트 상자 기본 여유치 보정(사방 0.1in)
const PAD = 0.1; // inches
const WIDTH_SCALE = 1.1; // 가로 폭 110% 확장
const tx = x - PAD;
const ty = y - PAD;
const tw = clampInch((w + PAD * 2) * WIDTH_SCALE);
const th = clampInch(h + PAD * 2);
// px->pt 변환 및 옵션 매핑
const fontSizePt = Math.max(1, pxToPt(Number(n.fontSize) || 14));
const alignRaw = String(n.align || 'left').toLowerCase();
const align = alignRaw === 'justified' ? 'justify' : alignRaw; // pptxgen: 'justify'
const vMap = { TOP: 'top', CENTER: 'middle', BOTTOM: 'bottom' };
const valign = vMap[String(n.valign || '').toUpperCase()] || 'top';
const topStyle = n.fontWeight ? styleFromWeight(n.fontWeight) : (n.bold ? 'Bold' : (n.fontStyle || ''));
const topFontFace = resolveFontFaceWithStyle(n.fontFamily || CURRENT_FONT, topStyle);
let lineSpacing;
let lineSpacingMultiple;
if (n.paragraph && n.paragraph.line) {
const ln = n.paragraph.line;
if (ln.rule === 'exact' && typeof ln.value === 'number') {
lineSpacing = Math.max(1, Math.round(Number(ln.value)));
} else if (ln.rule === 'multiple' && typeof ln.value === 'number') {
// percent -> multiple (eg. 150 -> 1.5)
lineSpacingMultiple = Math.max(0.5, Math.min(9.99, Number(ln.value) / 100));
}
}
const cs = safeNum(n.charSpacingPt, 0);
// PPTX charSpacing는 pt 단위. 좁게(음수)도 허용하되 과도한 값은 클램프
const charSpacing = Number.isFinite(cs) && cs !== 0
? Math.round(Math.max(-1000, Math.min(1000, cs)))
: undefined;
// 런 단위 스타일 적용: 폰트 패밀리는 폴백을 사용하되 weight/italic/underline/strike 반영
const hasRuns = Array.isArray(n.runs) && n.runs.length > 0;
const textRuns = hasRuns
? n.runs.map((r) => {
const runFontPt = typeof r.fontSize === 'number' ? Math.max(1, pxToPt(r.fontSize)) : fontSizePt;
const runColor = sanitizeHex(r.color || n.color || '000000');
const fontStyle = String(r.fontStyle || '').toLowerCase();
const isBold = typeof r.bold === 'boolean' ? r.bold : (/bold|black|heavy|semi|demi|extrabold|700|800|900|medium/.test(fontStyle) || !!n.bold);
const isItalic = typeof r.italic === 'boolean' ? r.italic : (/italic|oblique/.test(fontStyle) || !!n.italic);
const runCharSpacing = Number.isFinite(Number(r.charSpacingPt)) && Number(r.charSpacingPt) !== 0
? Math.round(Math.max(-1000, Math.min(1000, Number(r.charSpacingPt))))
: undefined;
const runStyle = r.fontWeight ? styleFromWeight(r.fontWeight) : (isBold ? 'Bold' : (r.fontStyle || n.fontStyle || ''));
const runFontFace = resolveFontFaceWithStyle(r.fontFamily || topFontFace, runStyle);
return {
text: String(r.text || ''),
options: {
fontFace: runFontFace,
fontSize: runFontPt,
color: runColor,
bold: isBold,
italic: isItalic,
underline: !!r.underline,
strike: !!r.strike,
...(typeof runCharSpacing === 'number' ? { charSpacing: runCharSpacing } : {}),
},
};
})
.filter((rr) => rr.text && rr.text.length > 0)
: undefined;
const textContent = textRuns && textRuns.length > 0 ? textRuns : (n.text || '');
slide.addText(textContent, {
x: tx, y: ty, w: tw, h: th,
fontSize: fontSizePt,
color: sanitizeHex(n.color || '000000'),
align,
valign,
rotate: rotateVal,
fontFace: topFontFace,
bold: !!n.bold,
italic: !!n.italic,
margin: 0,
...(typeof lineSpacing === 'number' ? { lineSpacing } : {}),
...(typeof lineSpacingMultiple === 'number' ? { lineSpacingMultiple } : {}),
...(typeof charSpacing === 'number' ? { charSpacing } : {}),
});
console.log('[agent] addText', { ...baseLog, padIn: PAD, widthScale: WIDTH_SCALE, final: { xIn: tx, yIn: ty, wIn: tw, hIn: th }, fontSizePt, color: sanitizeHex(n.color || '000000'), align, valign, fontFace: topFontFace, lineSpacing, lineSpacingMultiple, charSpacing, runs: hasRuns ? (textRuns ? textRuns.length : 0) : 0 });
}
});
});
const outDir = getDownloadsDir();
const outPath = path.join(outDir, `FigmaToPPTX-${Date.now()}.pptx`);
await pres.writeFile({ fileName: outPath });
const openScript = `tell application "Microsoft PowerPoint" to open POSIX file "${outPath.replace(/"/g, '\\"')}"`;
const activateScript = 'tell application "Microsoft PowerPoint" to activate';
await new Promise((resolve, reject) => {
execFile('osascript', ['-e', openScript, '-e', activateScript], (error, stdout, stderr) => {
if (error) return reject(new Error(stderr || error.message));
resolve(stdout);
});
});
// SVG 큐가 있으면 VBA로 벡터 삽입
if (ENABLE_SVG_VECTOR_INSERT && svgQueue.length > 0) {
const vba = [];
vba.push('On Error Resume Next');
svgQueue.forEach((t, idx) => {
const fileWinPath = t.path.replace(/\\/g, '/').replace(/"/g, '""');
vba.push(`Set s = ActivePresentation.Slides(${t.slide})`);
vba.push(`Set shp = s.Shapes.AddPicture("${fileWinPath}", msoFalse, msoTrue, ${t.L}, ${t.T}, ${t.W}, ${t.H})`);
if (t.rotate) {
vba.push(`shp.Rotation = ${Number(t.rotate) || 0}`);
}
});
const vbaScript = `tell application "Microsoft PowerPoint" to do Visual Basic "${vba.join('\n').replace(/"/g, '""')}"`;
await new Promise((resolve, reject) => {
execFile('osascript', ['-e', vbaScript], (error, stdout, stderr) => {
if (error) return reject(new Error(stderr || error.message));
resolve(stdout);
});
});
}
res.json({ ok: true, path: outPath });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Unexpected error', detail: String(err) });
}
});
function handleOpen(req, res) {
try {
if (process.platform !== 'darwin') {
return res.status(400).json({ error: 'This agent only supports macOS.' });
}
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
const fileName = sanitizeFilename(req.file.originalname || `Figma-Export-${Date.now()}.pptx`);
const ext = path.extname(fileName).toLowerCase();
if (ext !== '.pptx') {
return res.status(400).json({ error: 'Only .pptx is supported' });
}
// Best-effort MIME check
const mime = req.file.mimetype || '';
if (mime && mime !== 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
// Allow if unknown or empty; reject clearly wrong types
const allowed = ['application/octet-stream', ''];
if (!allowed.includes(mime)) {
return res.status(400).json({ error: `Unexpected MIME type: ${mime}` });
}
}
const tmpPath = path.join(getDownloadsDir(), fileName);
fs.writeFileSync(tmpPath, req.file.buffer);
const openScript = `tell application "Microsoft PowerPoint" to open POSIX file "${tmpPath.replace(/"/g, '\\"')}"`;
const activateScript = 'tell application "Microsoft PowerPoint" to activate';
execFile('osascript', ['-e', openScript, '-e', activateScript], (error, stdout, stderr) => {
if (error) {
console.error('osascript error:', stderr || error.message);
return res.status(500).json({ error: 'Failed to open in PowerPoint', detail: stderr || error.message });
}
res.json({ ok: true, path: tmpPath });
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Unexpected error', detail: String(err) });
}
}
function sanitizeFilename(name) {
return name.replace(/[^a-zA-Z0-9._-]/g, '_');
}
app.listen(PORT, BIND_HOST, () => {
console.log(`PPT local agent v${APP_VERSION} listening on http://${BIND_HOST}:${PORT}`);
if (AUTH_TOKEN) {
console.log('Auth: bearer token required');
} else {
console.log('Auth: disabled');
}
});