补充运行说明并实现基础导出能力
This commit is contained in:
@@ -1,14 +1,74 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import PlainTextResponse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import PlainTextResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from models.document import Document
|
||||
from models.generation_log import GenerationLog
|
||||
from models.paragraph import Paragraph
|
||||
from models.template import Template
|
||||
from services.document_export import export_document_bytes
|
||||
from services.minio_client import (
|
||||
download_object_bytes,
|
||||
get_presigned_url,
|
||||
split_bucket_path,
|
||||
upload_bytes,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{document_id}/docx")
|
||||
async def export_docx(document_id: int):
|
||||
return PlainTextResponse(
|
||||
f"文档 {document_id} 的 Word 导出功能正在开发中,当前版本请先使用预览页查看结果。",
|
||||
media_type="text/plain; charset=utf-8",
|
||||
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||
document = await db.get(Document, document_id)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=404, detail="生成记录不存在")
|
||||
|
||||
template = await db.get(Template, document.template_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
template_bucket, template_object = split_bucket_path(template.file_path)
|
||||
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
|
||||
|
||||
result = await db.execute(
|
||||
select(GenerationLog, Paragraph)
|
||||
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
||||
.where(GenerationLog.document_id == document_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
)
|
||||
logs = []
|
||||
for log, paragraph in result.all():
|
||||
logs.append(
|
||||
{
|
||||
"title": paragraph.title,
|
||||
"content": json.loads(log.content) if log.content else {"content": []},
|
||||
}
|
||||
)
|
||||
|
||||
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
|
||||
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
|
||||
await asyncio.to_thread(
|
||||
upload_bytes,
|
||||
settings.MINIO_BUCKET_OUTPUTS,
|
||||
object_name,
|
||||
exported_bytes,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
|
||||
document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}"
|
||||
await db.commit()
|
||||
return RedirectResponse(
|
||||
url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name),
|
||||
status_code=307,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document
|
||||
from docx.document import Document as DocumentObject
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.table import CT_Tbl
|
||||
from docx.oxml.text.paragraph import CT_P
|
||||
from docx.table import Table, _Cell
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
|
||||
def _iter_block_items(parent: DocumentObject | _Cell):
|
||||
parent_elm = parent.element.body if isinstance(parent, DocumentObject) else parent._tc
|
||||
for child in parent_elm.iterchildren():
|
||||
if isinstance(child, CT_P):
|
||||
yield Paragraph(child, parent)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
yield Table(child, parent)
|
||||
|
||||
|
||||
def _is_heading(paragraph: Paragraph) -> bool:
|
||||
style_name = paragraph.style.name if paragraph.style is not None else ""
|
||||
normalized = style_name.lower().replace(" ", "")
|
||||
return normalized.startswith("heading")
|
||||
|
||||
|
||||
def _delete_block(block):
|
||||
element = block._element
|
||||
parent = element.getparent()
|
||||
if parent is not None:
|
||||
parent.remove(element)
|
||||
|
||||
|
||||
def _clear_paragraph(paragraph: Paragraph):
|
||||
element = paragraph._element
|
||||
for child in list(element):
|
||||
if child.tag.endswith("}r"):
|
||||
element.remove(child)
|
||||
|
||||
|
||||
def _append_paragraph_after(paragraph: Paragraph, text: str, style_name: str | None = None) -> Paragraph:
|
||||
new_p = OxmlElement("w:p")
|
||||
paragraph._element.addnext(new_p)
|
||||
new_para = Paragraph(new_p, paragraph._parent)
|
||||
if style_name:
|
||||
try:
|
||||
new_para.style = style_name
|
||||
except Exception:
|
||||
pass
|
||||
if text:
|
||||
new_para.add_run(text)
|
||||
return new_para
|
||||
|
||||
|
||||
def _append_table_after(paragraph: Paragraph, rows: list[list[str]], headers: list[str] | None = None):
|
||||
container = paragraph._parent
|
||||
table = container.add_table(rows=1, cols=max(len(headers or []), len(rows[0]) if rows else 1))
|
||||
if headers:
|
||||
header_cells = table.rows[0].cells
|
||||
for index, value in enumerate(headers):
|
||||
header_cells[index].text = value
|
||||
else:
|
||||
if rows:
|
||||
first = rows.pop(0)
|
||||
for index, value in enumerate(first):
|
||||
table.rows[0].cells[index].text = value
|
||||
for row in rows:
|
||||
new_row = table.add_row().cells
|
||||
for index, value in enumerate(row):
|
||||
new_row[index].text = value
|
||||
|
||||
tbl = table._tbl
|
||||
tbl.getparent().remove(tbl)
|
||||
paragraph._element.addnext(tbl)
|
||||
return Table(tbl, container)
|
||||
|
||||
|
||||
def _append_empty_paragraph_after_table(table: Table, style_name: str | None = None) -> Paragraph:
|
||||
new_p = OxmlElement("w:p")
|
||||
table._tbl.addnext(new_p)
|
||||
new_para = Paragraph(new_p, table._parent)
|
||||
if style_name:
|
||||
try:
|
||||
new_para.style = style_name
|
||||
except Exception:
|
||||
pass
|
||||
return new_para
|
||||
|
||||
|
||||
def _find_heading_paragraph(document: DocumentObject, heading_text: str) -> Paragraph | None:
|
||||
for block in _iter_block_items(document):
|
||||
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
|
||||
return block
|
||||
return None
|
||||
|
||||
|
||||
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict):
|
||||
heading = _find_heading_paragraph(document, heading_title)
|
||||
if heading is None:
|
||||
return
|
||||
|
||||
first_body_style = None
|
||||
current = heading._element.getnext()
|
||||
blocks_to_remove = []
|
||||
while current is not None:
|
||||
if isinstance(current, CT_P):
|
||||
current_paragraph = Paragraph(current, heading._parent)
|
||||
if _is_heading(current_paragraph):
|
||||
break
|
||||
if first_body_style is None and current_paragraph.style is not None:
|
||||
first_body_style = current_paragraph.style.name
|
||||
blocks_to_remove.append(current_paragraph)
|
||||
elif isinstance(current, CT_Tbl):
|
||||
blocks_to_remove.append(Table(current, heading._parent))
|
||||
current = current.getnext()
|
||||
|
||||
for block in blocks_to_remove:
|
||||
_delete_block(block)
|
||||
|
||||
insert_after = heading
|
||||
content_blocks = content.get("content", [])
|
||||
for block in content_blocks:
|
||||
block_type = block.get("type")
|
||||
if block_type == "table":
|
||||
rows = [list(row) for row in block.get("rows", [])]
|
||||
headers = block.get("headers") or []
|
||||
table = _append_table_after(insert_after, rows, headers)
|
||||
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
|
||||
else:
|
||||
text = block.get("text", "")
|
||||
insert_after = _append_paragraph_after(insert_after, text, first_body_style)
|
||||
|
||||
|
||||
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
||||
document = Document(BytesIO(template_bytes))
|
||||
for item in logs:
|
||||
_replace_section_content(document, item["title"], item["content"])
|
||||
|
||||
output = BytesIO()
|
||||
document.save(output)
|
||||
return output.getvalue()
|
||||
@@ -1,3 +1,6 @@
|
||||
import io
|
||||
from datetime import timedelta
|
||||
|
||||
from minio import Minio
|
||||
from config import settings
|
||||
|
||||
@@ -34,4 +37,34 @@ def get_file_url(bucket: str, object_name: str) -> str:
|
||||
|
||||
def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str:
|
||||
"""获取预签名下载 URL(带过期时间)"""
|
||||
return minio_client.presigned_get_object(bucket, object_name, expires=expires)
|
||||
return minio_client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=expires))
|
||||
|
||||
|
||||
def split_bucket_path(file_path: str) -> tuple[str, str]:
|
||||
if "/" not in file_path:
|
||||
raise ValueError("非法的 MinIO 文件路径")
|
||||
return file_path.split("/", 1)
|
||||
|
||||
|
||||
def download_object_bytes(bucket: str, object_name: str) -> bytes:
|
||||
response = minio_client.get_object(bucket, object_name)
|
||||
try:
|
||||
return response.read()
|
||||
finally:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
|
||||
def upload_bytes(
|
||||
bucket: str,
|
||||
object_name: str,
|
||||
content: bytes,
|
||||
content_type: str = "application/octet-stream",
|
||||
):
|
||||
minio_client.put_object(
|
||||
bucket,
|
||||
object_name,
|
||||
io.BytesIO(content),
|
||||
len(content),
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user