UNPKG

powerpointem-all

Version:

PowerPoint'em All: Export selected frames from Figma to PowerPoint with local agent server.

684 lines (641 loc) 31.5 kB
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'); } });