在线PPT后端服务设计
概述
在线PPT编辑器的后端服务负责PPT数据的存储、AI生成、PPTX导入导出等核心功能。本文将深入介绍PPT后端服务的设计与实现,包括数据模型、AI生成流程、文件处理等关键环节。
服务架构
技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Node.js | ES Module | 运行环境 |
| Fastify | 5.2.1 | Web框架 |
| MongoDB | - | 数据存储 |
| Mongoose | 8.10.0 | ODM |
| Playwright | 1.55.0 | 浏览器自动化 |
| pptxtojson | 2.0.0 | PPTX解析 |
| pptxgenjs | 3.12.0 | PPTX生成 |
服务结构
pptonline.api/
├── app.js # Fastify应用入口
├── server.js # 独立启动入口
├── routes/
│ ├── ppt/root.js # PPT基础API
│ └── ppt-my/root.js # 用户PPT管理API
├── services/
│ ├── core/
│ │ ├── llm.js # AI PPT生成核心
│ │ ├── pptInfo.js # PPT信息CRUD
│ │ ├── importPptx.js # PPTX导入(724行)
│ │ └── tmpl.js # 模板管理
│ ├── agent/ # Agent任务框架
│ └── console-mock/ # Mock服务
├── daos/
│ └── core/
│ ├── schema/
│ │ └── pptInfoSchema.js
│ └── dac/
│ └── pptInfoDac.js
├── grpc/
│ ├── servers/
│ │ ├── pptInfo.js # PPT信息gRPC服务
│ │ └── agentExecutor.js # Agent执行服务
│ └── clients/ # gRPC客户端
├── types/
│ ├── shapes.js # 形状定义(1010行)
│ └── slides.js # 幻灯片类型
└── conf/
└── system.config.js # 系统配置
核心数据模型
PPT信息模型
// daos/core/schema/pptInfoSchema.js
import mongoose from 'mongoose'
const PptInfoSchema = new mongoose.Schema({
// 基本信息
pptCode: { type: String, required: true, unique: true },
title: { type: String, required: true },
description: { type: String, default: '' },
// 创建信息
createType: {
type: String,
enum: ['blank', 'template', 'ai', 'import'],
default: 'blank'
},
// 内容数据(JSON格式存储完整PPT)
content: {
type: mongoose.Schema.Types.Mixed,
default: () => ({
slides: [],
theme: {},
viewport: { width: 1000, height: 562.5 }
})
},
// 统计信息
slideCount: { type: Number, default: 0 },
wordCount: { type: Number, default: 0 },
// 文件信息(导入时使用)
fileInfo: {
originalName: String,
fileSize: Number,
filePath: String,
mimeType: String,
},
// AI生成信息
aiInfo: {
outline: String, // 生成大纲
model: String, // 使用的模型
prompt: String, // 提示词
generatedAt: Date,
},
// 模板信息
templateInfo: {
templateCode: String,
templateName: String,
},
// 权限
ownerId: { type: String, required: true },
collaborators: [{
userId: String,
role: { type: String, enum: ['viewer', 'editor', 'admin'] },
addedAt: Date,
}],
// 状态
status: {
type: String,
enum: ['active', 'archived', 'deleted'],
default: 'active'
},
// 标签
tags: [String],
// 时间戳
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
}, {
timestamps: true,
indexes: [
{ ownerId: 1, updatedAt: -1 },
{ status: 1, createType: 1 },
{ tags: 1 },
],
})
export default mongoose.model('PptInfo', PptInfoSchema)
幻灯片数据结构
// types/slides.js
// 幻灯片类型
export const SlideType = {
BLANK: 'blank', // 空白页
TITLE: 'title', // 标题页
CONTENT: 'content', // 内容页
TWO_COLUMN: 'two_column', // 两栏页
PICTURE: 'picture', // 图文页
CHART: 'chart', // 图表页
TABLE: 'table', // 表格页
END: 'end', // 结束页
}
// 幻灯片结构
export const SlideSchema = {
id: String, // 幻灯片ID
type: String, // 幻灯片类型
elements: [Element], // 元素列表
background: Background, // 背景设置
animations: [Animation], // 动画效果
notes: String, // 演讲者备注
transition: Transition, // 切换效果
}
// 元素类型
export const ElementTypes = {
TEXT: 'text',
IMAGE: 'image',
SHAPE: 'shape',
CHART: 'chart',
TABLE: 'table',
VIDEO: 'video',
AUDIO: 'audio',
}
// 元素基类
export const BaseElement = {
id: String,
type: String,
left: Number,
top: Number,
width: Number,
height: Number,
rotation: Number,
zIndex: Number,
}
AI PPT生成
1. 生成流程
用户输入主题
↓
生成PPT大纲(Markdown格式)
↓
选择/匹配模板
↓
生成PPT内容(JSON格式)
↓
合成PPT对象(Playwright渲染)
↓
存储到MongoDB
2. 大纲生成服务
// services/core/llm.js
import OpenAI from 'openai'
class LLMService {
constructor(fastify) {
this.fastify = fastify
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
}
// 生成PPT大纲
async generateOutline(topic, pages = 10, style = 'academic') {
const stylePrompts = {
academic: '学术风格,客观、严谨、专业',
business: '职场风格,简洁、决策导向',
education: '教育风格,易懂、互动、分层讲解',
marketing: '营销风格,有吸引力、情感化',
general: '通用风格,中性、普适、清晰',
}
const prompt = `请为"${topic}"主题生成一个PPT大纲,共${pages}页。
要求:
1. ${stylePrompts[style]}
2. 包含封面、目录、正文内容、总结/结束页
3. 每页需要标题和简要内容描述
4. 使用Markdown格式输出
请按以下格式输出:
# ${topic}
## 第1页:封面
- 主标题:xxx
- 副标题:xxx
## 第2页:目录
- 目录项1
- 目录项2
...
## 第3页:xxx(标题)
- 要点1
- 要点2
- 要点3
...
## 第${pages}页:总结/结束
- 核心观点
- 感谢语
`
const response = await this.openai.chat.completions.create({
model: 'gpt-4',
messages: [
{ role: 'system', content: '你是一位专业的PPT设计师,擅长根据主题生成结构清晰、内容丰富的PPT大纲。' },
{ role: 'user', content: prompt },
],
temperature: 0.7,
})
return response.choices[0].message.content
}
// 解析大纲为结构化数据
parseOutline(outlineText) {
const pages = []
const lines = outlineText.split('\n')
let currentPage = null
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
// 匹配页标题:## 第X页:标题
const pageMatch = trimmed.match(/^## 第(\d+)页[::]\s*(.+)$/)
if (pageMatch) {
if (currentPage) pages.push(currentPage)
currentPage = {
pageNumber: parseInt(pageMatch[1]),
title: pageMatch[2],
content: [],
}
}
// 匹配内容项:- 内容
else if (trimmed.startsWith('- ') && currentPage) {
currentPage.content.push(trimmed.substring(2))
}
// 匹配主标题:# 标题
else if (trimmed.startsWith('# ')) {
// 封面标题
if (!currentPage) {
currentPage = {
pageNumber: 1,
title: '封面',
content: [trimmed.substring(2)],
}
}
}
}
if (currentPage) pages.push(currentPage)
return pages
}
}
3. PPT内容生成
// services/core/llm.js (续)
class LLMService {
// 根据大纲生成完整PPT内容
async generatePPTContent(outline, template, style = 'academic') {
const pages = this.parseOutline(outline)
const slides = []
for (const page of pages) {
const slide = await this.generateSlide(page, template, style)
slides.push(slide)
}
return {
slides,
theme: template.theme,
viewport: { width: 1000, height: 562.5 },
}
}
// 生成单页幻灯片
async generateSlide(page, template, style) {
const pageType = this.determinePageType(page)
const prompt = `请为以下PPT页面生成详细内容:
页面标题:${page.title}
页面类型:${pageType}
内容要点:
${page.content.map(c => `- ${c}`).join('\n')}
风格:${style}
请生成JSON格式的幻灯片内容,包含:
1. 页面类型
2. 元素布局(文本框、图片位置)
3. 文本内容(HTML格式)
4. 样式设置
模板参考:
${JSON.stringify(template.slides[pageType] || template.slides.content, null, 2)}
`
const response = await this.openai.chat.completions.create({
model: 'gpt-4',
messages: [
{ role: 'system', content: '你是一位PPT设计专家,擅长将大纲转换为精美的幻灯片JSON数据。' },
{ role: 'user', content: prompt },
],
temperature: 0.7,
response_format: { type: 'json_object' },
})
const content = JSON.parse(response.choices[0].message.content)
return this.validateSlide(content)
}
// 判断页面类型
determinePageType(page) {
if (page.pageNumber === 1) return 'title'
if (page.title.includes('目录')) return 'table_of_contents'
if (page.title.includes('总结') || page.title.includes('结束')) return 'end'
if (page.content.length >= 4) return 'content'
return 'content'
}
// 验证和清理幻灯片数据
validateSlide(slide) {
// 确保必需的字段
const validated = {
id: generateUUID(),
type: slide.type || 'content',
elements: slide.elements || [],
background: slide.background || { type: 'color', value: '#ffffff' },
}
// 验证元素
validated.elements = validated.elements.map(el => ({
id: el.id || generateUUID(),
type: el.type || 'text',
left: el.left || 50,
top: el.top || 50,
width: el.width || 400,
height: el.height || 100,
...el,
}))
return validated
}
}
4. Agent任务编排
// services/agent/index.js
class AgentExecutor {
constructor(fastify) {
this.fastify = fastify
this.handlers = new Map()
this.registerHandlers()
}
registerHandlers() {
this.handlers.set('llm', new LLMHandler(this.fastify))
this.handlers.set('ppt-compose', new PPTComposeHandler(this.fastify))
this.handlers.set('grpc', new GRPCHandler(this.fastify))
}
// 执行AI PPT生成任务
async executeAIPPTGeneration(agentTask) {
const { subAgents } = agentTask
for (const subAgent of subAgents) {
const handler = this.handlers.get(subAgent.agentType)
if (!handler) {
throw new Error(`Unknown agent type: ${subAgent.agentType}`)
}
subAgent.status = 'running'
await this.updateAgentStatus(agentTask.agentCode, subAgent)
try {
const result = await handler.execute(subAgent)
subAgent.status = 'completed'
subAgent.output = result
subAgent.completedAt = new Date()
} catch (error) {
subAgent.status = 'failed'
subAgent.error = error.message
throw error
}
await this.updateAgentStatus(agentTask.agentCode, subAgent)
}
return agentTask
}
}
// PPT合成处理器
class PPTComposeHandler {
constructor(fastify) {
this.fastify = fastify
this.playwright = null
}
async execute(subAgent) {
const { slides, template } = subAgent.input
// 使用Playwright渲染PPT
const pptData = await this.composePPT(slides, template)
return pptData
}
async composePPT(slides, template) {
// 启动Playwright
const { chromium } = require('playwright')
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
// 设置视口
await page.setViewportSize({ width: 1000, height: 562.5 })
// 加载PPTist编辑器页面
await page.goto(process.env.PPTIST_EDITOR_URL)
// 注入PPT数据
await page.evaluate((data) => {
window.loadPPTData(data)
}, { slides, theme: template.theme })
// 等待渲染完成
await page.waitForTimeout(1000)
// 生成缩略图
const thumbnails = []
for (let i = 0; i < slides.length; i++) {
await page.evaluate((index) => {
window.setCurrentSlide(index)
}, i)
const screenshot = await page.screenshot({
type: 'png',
encoding: 'base64',
})
thumbnails.push(screenshot)
}
await browser.close()
return {
slides,
thumbnails,
slideCount: slides.length,
}
}
}
PPTX导入导出
1. PPTX导入
// services/core/importPptx.js
import { extractPptx } from 'pptxtojson'
import { chromium } from 'playwright'
class PPTXImporter {
constructor(fastify) {
this.fastify = fastify
}
async import(filePath) {
// 第一步:使用pptxtojson解析PPTX
const parsed = await extractPptx(filePath)
// 第二步:使用Playwright处理复杂样式
const processed = await this.processWithPlaywright(parsed)
// 第三步:转换为内部格式
const slides = this.convertToInternalFormat(processed)
return {
title: parsed.title || '导入的PPT',
slideCount: slides.length,
slides,
}
}
async processWithPlaywright(parsed) {
const browser = await chromium.launch()
const page = await browser.newPage()
const processedSlides = []
for (const slide of parsed.slides) {
// 构建HTML
const html = this.buildSlideHTML(slide)
// 使用Playwright渲染并计算样式
await page.setContent(html)
// 获取计算后的样式
const computedStyles = await page.evaluate(() => {
const elements = document.querySelectorAll('[data-ppt-element]')
return Array.from(elements).map(el => {
const rect = el.getBoundingClientRect()
const style = window.getComputedStyle(el)
return {
id: el.dataset.id,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
fontSize: style.fontSize,
color: style.color,
backgroundColor: style.backgroundColor,
}
})
})
processedSlides.push({
...slide,
computedStyles,
})
}
await browser.close()
return processedSlides
}
convertToInternalFormat(processedSlides) {
return processedSlides.map((slide, index) => ({
id: generateUUID(),
type: this.determineSlideType(slide),
elements: slide.elements.map(el => this.convertElement(el)),
background: this.convertBackground(slide.background),
}))
}
convertElement(element) {
const base = {
id: element.id || generateUUID(),
left: element.left || 0,
top: element.top || 0,
width: element.width || 100,
height: element.height || 50,
}
switch (element.type) {
case 'text':
return {
...base,
type: 'text',
content: element.text || '',
defaultFontName: element.fontName || '微软雅黑',
defaultColor: element.color || '#000000',
fontSize: element.fontSize || 18,
}
case 'image':
return {
...base,
type: 'image',
src: element.src || '',
}
case 'shape':
return {
...base,
type: 'shape',
shape: element.shape || 'rect',
fill: element.fill || '#ffffff',
}
default:
return base
}
}
}
2. PPTX导出
// services/core/exportPptx.js
import PptxGenJS from 'pptxgenjs'
class PPTXExporter {
async export(slides) {
const pres = new PptxGenJS()
// 设置默认主题
pres.layout = 'LAYOUT_16x9'
pres.author = 'PPTist'
pres.company = 'Micro Platform'
for (const slide of slides) {
const pptxSlide = pres.addSlide()
// 设置背景
if (slide.background) {
this.setBackground(pptxSlide, slide.background)
}
// 添加元素
for (const element of slide.elements) {
await this.addElement(pptxSlide, element)
}
}
return pres
}
setBackground(pptxSlide, background) {
if (background.type === 'color') {
pptxSlide.background = { color: background.value }
} else if (background.type === 'image') {
pptxSlide.background = { path: background.src }
}
}
async addElement(pptxSlide, element) {
const { type, left, top, width, height } = element
// 转换为PPTX单位(百分比)
const x = left / 1000
const y = top / 562.5
const w = width / 1000
const h = height / 562.5
switch (type) {
case 'text':
pptxSlide.addText(element.content, {
x, y, w, h,
fontSize: element.fontSize || 18,
fontFace: element.defaultFontName || 'Arial',
color: this.rgbToHex(element.defaultColor),
align: element.align || 'left',
})
break
case 'image':
// 下载图片
const imageData = await this.downloadImage(element.src)
pptxSlide.addImage({
data: imageData,
x, y, w, h,
})
break
case 'shape':
pptxSlide.addShape(element.shape, {
x, y, w, h,
fill: this.rgbToHex(element.fill),
})
break
case 'chart':
pptxSlide.addChart(element.chartType, element.data, {
x, y, w, h,
...element.options,
})
break
case 'table':
pptxSlide.addTable(element.data, {
x, y, w, h,
fontSize: 12,
border: { pt: 1, color: 'cccccc' },
})
break
}
}
rgbToHex(color) {
// 转换颜色格式
if (color.startsWith('#')) return color.substring(1)
if (color.startsWith('rgb')) {
const matches = color.match(/\d+/g)
if (matches) {
const [r, g, b] = matches.map(Number)
return ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')
}
}
return '000000'
}
async downloadImage(url) {
// 下载图片并转为base64
const response = await fetch(url)
const buffer = await response.arrayBuffer()
return 'data:image/png;base64,' + Buffer.from(buffer).toString('base64')
}
}
gRPC服务
PPT信息gRPC服务
// grpc/servers/pptInfo.js
import grpc from '@grpc/grpc-js'
import protoLoader from '@grpc/proto-loader'
const PROTO_PATH = './grpc/proto/pptInfo.proto'
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
})
const pptProto = grpc.loadPackageDefinition(packageDefinition).ppt
export default async function pptInfoServer(fastify) {
const server = new grpc.Server()
server.addService(pptProto.PptInfoService.service, {
// 创建PPT
createPPT: async (call, callback) => {
try {
const { title, ownerId, createType } = call.request
const ppt = await fastify.pptInfoService.create({
pptCode: generateUUID(),
title,
ownerId,
createType,
content: {
slides: [],
theme: {},
viewport: { width: 1000, height: 562.5 },
},
})
callback(null, {
pptCode: ppt.pptCode,
title: ppt.title,
status: 'success',
})
} catch (error) {
callback(error)
}
},
// 获取PPT
getPPT: async (call, callback) => {
try {
const { pptCode } = call.request
const ppt = await fastify.pptInfoService.getByCode(pptCode)
if (!ppt) {
return callback({
code: grpc.status.NOT_FOUND,
message: 'PPT not found',
})
}
callback(null, {
pptCode: ppt.pptCode,
title: ppt.title,
content: JSON.stringify(ppt.content),
slideCount: ppt.slideCount,
updatedAt: ppt.updatedAt.toISOString(),
})
} catch (error) {
callback(error)
}
},
// 复制PPT
copyPPT: async (call, callback) => {
try {
const { pptCode, newOwnerId } = call.request
const original = await fastify.pptInfoService.getByCode(pptCode)
if (!original) {
return callback({
code: grpc.status.NOT_FOUND,
message: 'Original PPT not found',
})
}
const copy = await fastify.pptInfoService.create({
pptCode: generateUUID(),
title: `${original.title} (副本)`,
ownerId: newOwnerId,
createType: 'copy',
content: original.content,
slideCount: original.slideCount,
})
callback(null, {
pptCode: copy.pptCode,
title: copy.title,
status: 'success',
})
} catch (error) {
callback(error)
}
},
// 导入PPT
importPPT: async (call, callback) => {
try {
const { filePath, ownerId } = call.request
const importer = new PPTXImporter(fastify)
const result = await importer.import(filePath)
const ppt = await fastify.pptInfoService.create({
pptCode: generateUUID(),
title: result.title,
ownerId,
createType: 'import',
content: {
slides: result.slides,
theme: {},
viewport: { width: 1000, height: 562.5 },
},
slideCount: result.slideCount,
})
callback(null, {
pptCode: ppt.pptCode,
title: ppt.title,
slideCount: ppt.slideCount,
status: 'success',
})
} catch (error) {
callback(error)
}
},
})
return server
}
总结
PPT后端服务的设计要点:
- JSON-First架构:PPT数据完全以JSON格式存储和处理
- AI集成:从大纲生成到内容填充的完整AI工作流
- Playwright渲染:服务端浏览器渲染处理复杂样式
- Agent编排:多步骤任务的自动执行
- 格式转换:完整的PPTX导入导出支持
- gRPC通信:高性能服务间调用
下一篇将介绍企业级PDF阅读器的架构设计。