feat: 阶段二 - 核心业务 API 实现
- Pydantic Schemas: AIModel, Template, GenerationPoint, GenerationTask - 文档处理服务: docx↔HTML (Mammoth), docx→PDF (reportlab) - AI 模型管理 API: CRUD + 启用/禁用切换 + API Key 加密 - 模板管理 API: 上传/转换HTML/在线编辑保存/下载(docx+pdf) - 生成点管理 API: CRUD + 参考文件上传 + 批量排序 - 17 个 API 端点注册,后端启动验证通过
This commit is contained in:
parent
b5fa343bf8
commit
aa84521449
|
|
@ -0,0 +1,120 @@
|
|||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.models.generation_point import GenerationPoint
|
||||
from app.models.template import Template
|
||||
from app.models.ai_model import AIModel
|
||||
from app.schemas.generation_point import (
|
||||
GenerationPointCreate,
|
||||
GenerationPointUpdate,
|
||||
GenerationPointResponse,
|
||||
BatchOrderUpdate,
|
||||
)
|
||||
from app.services.file_storage import save_upload, REF_FILES_DIR
|
||||
|
||||
router = APIRouter(prefix="/generation-points", tags=["生成点管理"])
|
||||
|
||||
|
||||
@router.post("", response_model=GenerationPointResponse)
|
||||
async def create_generation_point(
|
||||
template_id: uuid.UUID = Form(...),
|
||||
position: str = Form(...),
|
||||
prompt: str = Form(...),
|
||||
model_id: uuid.UUID | None = Form(None),
|
||||
order: int = Form(0),
|
||||
ref_file: UploadFile | None = File(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
|
||||
template_result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
if not template_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
if model_id:
|
||||
model_result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
if not model_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
|
||||
ref_file_path = None
|
||||
if ref_file:
|
||||
ref_file_path = await save_upload(ref_file, REF_FILES_DIR)
|
||||
|
||||
point = GenerationPoint(
|
||||
template_id=template_id,
|
||||
position=json.loads(position),
|
||||
prompt=prompt,
|
||||
model_id=model_id,
|
||||
order=order,
|
||||
ref_file_path=ref_file_path,
|
||||
)
|
||||
db.add(point)
|
||||
await db.flush()
|
||||
await db.refresh(point)
|
||||
return point
|
||||
|
||||
|
||||
@router.get("", response_model=list[GenerationPointResponse])
|
||||
async def list_generation_points(
|
||||
template_id: uuid.UUID = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = (
|
||||
select(GenerationPoint)
|
||||
.where(GenerationPoint.template_id == template_id)
|
||||
.order_by(GenerationPoint.order.asc(), GenerationPoint.created_at.asc())
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{point_id}", response_model=GenerationPointResponse)
|
||||
async def get_generation_point(point_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
return point
|
||||
|
||||
|
||||
@router.put("/{point_id}", response_model=GenerationPointResponse)
|
||||
async def update_generation_point(
|
||||
point_id: str,
|
||||
data: GenerationPointUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(point, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(point)
|
||||
return point
|
||||
|
||||
|
||||
@router.delete("/{point_id}")
|
||||
async def delete_generation_point(point_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
await db.delete(point)
|
||||
return {"detail": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/batch-order")
|
||||
async def batch_update_order(data: BatchOrderUpdate, db: AsyncSession = Depends(get_db)):
|
||||
for item in data.points:
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == item["id"]))
|
||||
point = result.scalar_one_or_none()
|
||||
if point:
|
||||
point.order = item["order"]
|
||||
await db.flush()
|
||||
return {"detail": "排序更新成功"}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security import encrypt_api_key
|
||||
from app.models.ai_model import AIModel
|
||||
from app.schemas.ai_model import AIModelCreate, AIModelUpdate, AIModelToggle, AIModelResponse
|
||||
|
||||
router = APIRouter(prefix="/models", tags=["AI模型管理"])
|
||||
|
||||
|
||||
@router.post("", response_model=AIModelResponse)
|
||||
async def create_model(data: AIModelCreate, db: AsyncSession = Depends(get_db)):
|
||||
encrypted_key = encrypt_api_key(data.api_key)
|
||||
model = AIModel(
|
||||
name=data.name,
|
||||
provider=data.provider,
|
||||
endpoint=data.endpoint,
|
||||
api_key=encrypted_key,
|
||||
extra_params=data.extra_params,
|
||||
is_enabled=data.is_enabled,
|
||||
remark=data.remark,
|
||||
)
|
||||
db.add(model)
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
model.api_key = "***"
|
||||
return model
|
||||
|
||||
|
||||
@router.get("", response_model=list[AIModelResponse])
|
||||
async def list_models(
|
||||
enabled: bool | None = Query(None, description="过滤启用/禁用"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(AIModel)
|
||||
if enabled is not None:
|
||||
query = query.where(AIModel.is_enabled == enabled)
|
||||
query = query.offset(skip).limit(limit).order_by(AIModel.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
models = result.scalars().all()
|
||||
for m in models:
|
||||
m.api_key = "***"
|
||||
return models
|
||||
|
||||
|
||||
@router.get("/{model_id}", response_model=AIModelResponse)
|
||||
async def get_model(model_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
model.api_key = "***"
|
||||
return model
|
||||
|
||||
|
||||
@router.put("/{model_id}", response_model=AIModelResponse)
|
||||
async def update_model(model_id: str, data: AIModelUpdate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "api_key" in update_data and update_data["api_key"] is not None:
|
||||
update_data["api_key"] = encrypt_api_key(update_data["api_key"])
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(model, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
model.api_key = "***"
|
||||
return model
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
async def delete_model(model_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
await db.delete(model)
|
||||
return {"detail": "删除成功"}
|
||||
|
||||
|
||||
@router.patch("/{model_id}/toggle", response_model=AIModelResponse)
|
||||
async def toggle_model(model_id: str, data: AIModelToggle, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
model.is_enabled = data.is_enabled
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
model.api_key = "***"
|
||||
return model
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
from fastapi import APIRouter
|
||||
from app.api.models import router as models_router
|
||||
from app.api.templates import router as templates_router
|
||||
from app.api.generation_points import router as generation_points_router
|
||||
from app.api.tasks import router as tasks_router
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
api_router = APIRouter(prefix=settings.API_V1_PREFIX)
|
||||
api_router.include_router(models_router)
|
||||
api_router.include_router(templates_router)
|
||||
api_router.include_router(generation_points_router)
|
||||
api_router.include_router(tasks_router)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["生成任务"])
|
||||
|
||||
# 任务管理 API 将在阶段三实现
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.models.template import Template
|
||||
from app.schemas.template import TemplateResponse, TemplateListItem, HTMLContentResponse, HTMLUpdateRequest
|
||||
from app.services.file_storage import save_upload, get_file_content, delete_file, TEMPLATES_DIR
|
||||
from app.services.document_processor import docx_to_html, html_to_docx_bytes, docx_to_pdf_bytes
|
||||
|
||||
router = APIRouter(prefix="/templates", tags=["模板管理"])
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateResponse)
|
||||
async def upload_template(
|
||||
file: UploadFile = File(...),
|
||||
name: str | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not file.filename or not file.filename.endswith(".docx"):
|
||||
raise HTTPException(status_code=400, detail="仅支持 .docx 文件")
|
||||
|
||||
file_path = await save_upload(file, TEMPLATES_DIR)
|
||||
file_content = await get_file_content(file_path)
|
||||
html_content = await docx_to_html(file_content)
|
||||
|
||||
template = Template(
|
||||
name=name or file.filename.replace(".docx", ""),
|
||||
file_path=file_path,
|
||||
html_content=html_content,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
@router.get("", response_model=list[TemplateListItem])
|
||||
async def list_templates(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Template).offset(skip).limit(limit).order_by(Template.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateResponse)
|
||||
async def get_template(template_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return template
|
||||
|
||||
|
||||
@router.get("/{template_id}/html", response_model=HTMLContentResponse)
|
||||
async def get_template_html(template_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return HTMLContentResponse(html_content=template.html_content or "")
|
||||
|
||||
|
||||
@router.put("/{template_id}/html")
|
||||
async def update_template_html(
|
||||
template_id: str,
|
||||
data: HTMLUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
template.html_content = data.html_content
|
||||
|
||||
docx_bytes = html_to_docx_bytes(data.html_content)
|
||||
with open(template.file_path, "wb") as f:
|
||||
f.write(docx_bytes)
|
||||
|
||||
await db.flush()
|
||||
return {"detail": "保存成功"}
|
||||
|
||||
|
||||
@router.get("/{template_id}/download")
|
||||
async def download_template(
|
||||
template_id: str,
|
||||
format: str = Query("docx", pattern="^(docx|pdf)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
file_content = await get_file_content(template.file_path)
|
||||
|
||||
if format == "pdf":
|
||||
file_content = docx_to_pdf_bytes(file_content)
|
||||
media_type = "application/pdf"
|
||||
filename = f"{template.name}.pdf"
|
||||
else:
|
||||
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
filename = f"{template.name}.docx"
|
||||
|
||||
return Response(
|
||||
content=file_content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_template(template_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
delete_file(template.file_path)
|
||||
await db.delete(template)
|
||||
return {"detail": "删除成功"}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
from app.api.router import api_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
|
@ -17,6 +18,8 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AIModelCreate(BaseModel):
|
||||
name: str = Field(..., max_length=100)
|
||||
provider: str = Field(..., max_length=50)
|
||||
endpoint: str = Field(..., max_length=255)
|
||||
api_key: str
|
||||
extra_params: dict = Field(default_factory=dict)
|
||||
is_enabled: bool = True
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class AIModelUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
provider: str | None = Field(None, max_length=50)
|
||||
endpoint: str | None = Field(None, max_length=255)
|
||||
api_key: str | None = None
|
||||
extra_params: dict | None = None
|
||||
is_enabled: bool | None = None
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class AIModelToggle(BaseModel):
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class AIModelResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
provider: str
|
||||
endpoint: str
|
||||
api_key: str = Field(default="***")
|
||||
extra_params: dict
|
||||
is_enabled: bool
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GenerationPointCreate(BaseModel):
|
||||
template_id: uuid.UUID
|
||||
position: dict = Field(..., description="选区位置信息")
|
||||
prompt: str
|
||||
model_id: uuid.UUID | None = None
|
||||
order: int = 0
|
||||
|
||||
|
||||
class GenerationPointUpdate(BaseModel):
|
||||
position: dict | None = None
|
||||
prompt: str | None = None
|
||||
model_id: uuid.UUID | None = None
|
||||
order: int | None = None
|
||||
|
||||
|
||||
class GenerationPointResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
template_id: uuid.UUID
|
||||
position: dict
|
||||
prompt: str
|
||||
model_id: uuid.UUID | None = None
|
||||
ref_file_path: str | None = None
|
||||
order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BatchOrderUpdate(BaseModel):
|
||||
points: list[dict] = Field(..., description="[{id: uuid, order: int}, ...]")
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
template_id: uuid.UUID
|
||||
status: str
|
||||
result_file_path: str | None = None
|
||||
error_msg: str | None = None
|
||||
created_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
task_id: uuid.UUID
|
||||
status: str
|
||||
|
||||
|
||||
class SingleTestRequest(BaseModel):
|
||||
prompt: str
|
||||
model_id: uuid.UUID | None = None
|
||||
ref_file_path: str | None = None
|
||||
|
||||
|
||||
class SingleTestResponse(BaseModel):
|
||||
result: str
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
file_path: str
|
||||
html_content: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TemplateListItem(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class HTMLContentResponse(BaseModel):
|
||||
html_content: str
|
||||
|
||||
|
||||
class HTMLUpdateRequest(BaseModel):
|
||||
html_content: str
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import mammoth
|
||||
from io import BytesIO
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
from app.services.file_storage import get_file_content
|
||||
|
||||
|
||||
async def docx_to_html(file_content: bytes) -> str:
|
||||
result = mammoth.convert_to_html(BytesIO(file_content))
|
||||
return result.value
|
||||
|
||||
|
||||
def html_to_docx_bytes(html_content: str) -> bytes:
|
||||
doc = Document()
|
||||
|
||||
style = doc.styles["Normal"]
|
||||
font = style.font
|
||||
font.name = "Arial"
|
||||
font.size = Pt(11)
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class DocxHTMLParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.in_p = False
|
||||
self.current_text = ""
|
||||
self.paragraphs: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ("p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "li"):
|
||||
self.in_p = True
|
||||
self.current_text = ""
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "li"):
|
||||
if self.current_text.strip():
|
||||
self.paragraphs.append(self.current_text.strip())
|
||||
self.current_text = ""
|
||||
self.in_p = False
|
||||
|
||||
def handle_data(self, data):
|
||||
self.current_text += data
|
||||
|
||||
parser = DocxHTMLParser()
|
||||
parser.feed(html_content)
|
||||
|
||||
for text in parser.paragraphs:
|
||||
doc.add_paragraph(text)
|
||||
|
||||
output = BytesIO()
|
||||
doc.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def docx_to_pdf_bytes(file_content: bytes) -> bytes:
|
||||
doc = Document(BytesIO(file_content))
|
||||
|
||||
from io import BytesIO as Bio
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
|
||||
buffer = Bio()
|
||||
pdf_doc = SimpleDocTemplate(buffer, pagesize=A4)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
story = []
|
||||
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
p = Paragraph(para.text, styles["Normal"])
|
||||
story.append(p)
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
pdf_doc.build(story)
|
||||
return buffer.getvalue()
|
||||
Loading…
Reference in New Issue