revert: pdf gen

This commit is contained in:
Timothy J. Baek 2024-10-13 00:05:28 -07:00
parent 5dc05eac67
commit 112cbdccbb
14 changed files with 249 additions and 163 deletions

View File

@ -56,8 +56,17 @@ class ChatForm(BaseModel):
async def download_chat_as_pdf( async def download_chat_as_pdf(
form_data: ChatTitleMessagesForm, form_data: ChatTitleMessagesForm,
): ):
response = PDFGenerator(form_data).generate_chat_pdf() try:
return response pdf_bytes = PDFGenerator(form_data).generate_chat_pdf()
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": "attachment;filename=chat.pdf"},
)
except Exception as e:
print(e)
raise HTTPException(status_code=400, detail=str(e))
@router.get("/db/download") @router.get("/db/download")

View File

@ -230,6 +230,8 @@ if FROM_INIT_PY:
DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data")) DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data"))
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static"))
FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts")) FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts"))
FRONTEND_BUILD_DIR = Path(os.getenv("FRONTEND_BUILD_DIR", BASE_DIR / "build")).resolve() FRONTEND_BUILD_DIR = Path(os.getenv("FRONTEND_BUILD_DIR", BASE_DIR / "build")).resolve()

View File

@ -1,190 +1,230 @@
/* HTML and Body */ /* HTML and Body */
html { @font-face {
box-sizing: border-box; font-family: 'NotoSans';
font-size: 14px; /* Default font size */ src: url('fonts/NotoSans-Variable.ttf');
line-height: 1.5;
} }
*, *::before, *::after { @font-face {
box-sizing: inherit; font-family: 'NotoSansJP';
src: url('fonts/NotoSansJP-Variable.ttf');
}
@font-face {
font-family: 'NotoSansKR';
src: url('fonts/NotoSansKR-Variable.ttf');
}
@font-face {
font-family: 'NotoSansSC';
src: url('fonts/NotoSansSC-Variable.ttf');
}
@font-face {
font-family: 'NotoSansSC-Regular';
src: url('fonts/NotoSansSC-Regular.ttf');
}
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
'NotoSansSC', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium', Roboto,
'Helvetica Neue', Arial, sans-serif;
font-size: 14px; /* Default font size */
line-height: 1.5;
}
*,
*::before,
*::after {
box-sizing: inherit;
} }
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color: #212529;
color: #212529; background-color: #fff;
background-color: #fff; width: auto;
width: auto;
} }
/* Typography */ /* Typography */
h1, h2, h3, h4, h5, h6 { h1,
font-weight: 500; h2,
margin: 0; h3,
h4,
h5,
h6 {
font-weight: 500;
margin: 0;
} }
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
} }
h2 { h2 {
font-size: 2rem; font-size: 2rem;
} }
h3 { h3 {
font-size: 1.75rem; font-size: 1.75rem;
} }
h4 { h4 {
font-size: 1.5rem; font-size: 1.5rem;
} }
h5 { h5 {
font-size: 1.25rem; font-size: 1.25rem;
} }
h6 { h6 {
font-size: 1rem; font-size: 1rem;
} }
p { p {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Grid System */ /* Grid System */
.container { .container {
width: 100%; width: 100%;
padding-right: 15px; padding-right: 15px;
padding-left: 15px; padding-left: 15px;
margin-right: auto; margin-right: auto;
margin-left: auto; margin-left: auto;
} }
/* Utilities */ /* Utilities */
.text-center { .text-center {
text-align: center; text-align: center;
} }
/* Additional Text Utilities */ /* Additional Text Utilities */
.text-muted { .text-muted {
color: #6c757d; /* Muted text color */ color: #6c757d; /* Muted text color */
} }
/* Small Text */ /* Small Text */
small { small {
font-size: 80%; /* Smaller font size relative to the base */ font-size: 80%; /* Smaller font size relative to the base */
color: #6c757d; /* Lighter text color for secondary information */ color: #6c757d; /* Lighter text color for secondary information */
margin-bottom: 0; margin-bottom: 0;
margin-top: 0; margin-top: 0;
} }
/* Strong Element Styles */ /* Strong Element Styles */
strong { strong {
font-weight: bolder; /* Ensures the text is bold */ font-weight: bolder; /* Ensures the text is bold */
color: inherit; /* Inherits the color from its parent element */ color: inherit; /* Inherits the color from its parent element */
} }
/* link */ /* link */
a { a {
color: #007bff; color: #007bff;
text-decoration: none; text-decoration: none;
background-color: transparent; background-color: transparent;
} }
a:hover { a:hover {
color: #0056b3; color: #0056b3;
text-decoration: underline; text-decoration: underline;
} }
/* General styles for lists */ /* General styles for lists */
ol, ul, li { ol,
padding-left: 40px; /* Increase padding to move bullet points to the right */ ul,
margin-left: 20px; /* Indent lists from the left */ li {
padding-left: 40px; /* Increase padding to move bullet points to the right */
margin-left: 20px; /* Indent lists from the left */
} }
/* Ordered list styles */ /* Ordered list styles */
ol { ol {
list-style-type: decimal; /* Use numbers for ordered lists */ list-style-type: decimal; /* Use numbers for ordered lists */
margin-bottom: 10px; /* Space after each list */ margin-bottom: 10px; /* Space after each list */
} }
ol li { ol li {
margin-bottom: 0.5rem; /* Space between ordered list items */ margin-bottom: 0.5rem; /* Space between ordered list items */
} }
/* Unordered list styles */ /* Unordered list styles */
ul { ul {
list-style-type: disc; /* Use bullets for unordered lists */ list-style-type: disc; /* Use bullets for unordered lists */
margin-bottom: 10px; /* Space after each list */ margin-bottom: 10px; /* Space after each list */
} }
ul li { ul li {
margin-bottom: 0.5rem; /* Space between unordered list items */ margin-bottom: 0.5rem; /* Space between unordered list items */
} }
/* List item styles */ /* List item styles */
li { li {
margin-bottom: 5px; /* Space between list items */ margin-bottom: 5px; /* Space between list items */
line-height: 1.5; /* Line height for better readability */ line-height: 1.5; /* Line height for better readability */
} }
/* Nested lists */ /* Nested lists */
ol ol, ol ul, ul ol, ul ul { ol ol,
padding-left: 20px; ol ul,
margin-left: 30px; /* Further indent nested lists */ ul ol,
margin-bottom: 0; /* Remove extra margin at the bottom of nested lists */ ul ul {
padding-left: 20px;
margin-left: 30px; /* Further indent nested lists */
margin-bottom: 0; /* Remove extra margin at the bottom of nested lists */
} }
/* Code blocks */ /* Code blocks */
pre { pre {
background-color: #f4f4f4; background-color: #f4f4f4;
padding: 10px; padding: 10px;
overflow-x: auto; overflow-x: auto;
max-width: 100%; /* Ensure it doesn't overflow the page */ max-width: 100%; /* Ensure it doesn't overflow the page */
width: 80%; /* Set a specific width for a container-like appearance */ width: 80%; /* Set a specific width for a container-like appearance */
margin: 0 1em; /* Center the pre block */ margin: 0 1em; /* Center the pre block */
box-sizing: border-box; /* Include padding in the width */ box-sizing: border-box; /* Include padding in the width */
border: 1px solid #ccc; /* Optional: Add a border for better definition */ border: 1px solid #ccc; /* Optional: Add a border for better definition */
border-radius: 4px; /* Optional: Add rounded corners */ border-radius: 4px; /* Optional: Add rounded corners */
} }
code { code {
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
background-color: #f4f4f4; background-color: #f4f4f4;
padding: 2px 4px; padding: 2px 4px;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box; /* Include padding in the width */ box-sizing: border-box; /* Include padding in the width */
} }
.message { .message {
margin-top: 8px; margin-top: 8px;
margin-bottom: 8px; margin-bottom: 8px;
max-width: 100%;
overflow-wrap: break-word;
} }
/* Table Styles */ /* Table Styles */
table { table {
width: 100%; width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
color: #212529; color: #212529;
border-collapse: collapse; /* Removes the space between borders */ border-collapse: collapse; /* Removes the space between borders */
} }
th, td { th,
margin: 0; td {
padding: 0.75rem; margin: 0;
vertical-align: top; padding: 0.75rem;
border-top: 1px solid #dee2e6; vertical-align: top;
border-top: 1px solid #dee2e6;
} }
thead th { thead th {
vertical-align: bottom; vertical-align: bottom;
border-bottom: 2px solid #dee2e6; border-bottom: 2px solid #dee2e6;
} }
tbody + tbody { tbody + tbody {
border-top: 2px solid #dee2e6; border-top: 2px solid #dee2e6;
} }
/* markdown-section styles */ /* markdown-section styles */
@ -199,8 +239,8 @@ tbody + tbody {
.markdown-section pre, .markdown-section pre,
.markdown-section table, .markdown-section table,
.markdown-section ul { .markdown-section ul {
/* Give most block elements margin top and bottom */ /* Give most block elements margin top and bottom */
margin-top: 1rem; margin-top: 1rem;
} }
/* Remove top margin if it's the first child */ /* Remove top margin if it's the first child */
@ -215,69 +255,65 @@ tbody + tbody {
.markdown-section pre:first-child, .markdown-section pre:first-child,
.markdown-section table:first-child, .markdown-section table:first-child,
.markdown-section ul:first-child { .markdown-section ul:first-child {
margin-top: 0; margin-top: 0;
} }
/* Remove top margin of <ul> following a <p> */ /* Remove top margin of <ul> following a <p> */
.markdown-section p + ul { .markdown-section p + ul {
margin-top: 0; margin-top: 0;
} }
/* Remove bottom margin of <p> if it is followed by a <ul> */ /* Remove bottom margin of <p> if it is followed by a <ul> */
/* Note: :has is not supported in CSS, so you would need JavaScript for this behavior */ /* Note: :has is not supported in CSS, so you would need JavaScript for this behavior */
.markdown-section p { .markdown-section p {
margin-bottom: 0; margin-bottom: 0;
} }
/* Add a rule to reset margin-bottom for <p> not followed by <ul> */ /* Add a rule to reset margin-bottom for <p> not followed by <ul> */
.markdown-section p + ul { .markdown-section p + ul {
margin-top: 0; margin-top: 0;
} }
/* List item styles */ /* List item styles */
.markdown-section li { .markdown-section li {
padding: 2px; padding: 2px;
} }
.markdown-section li p { .markdown-section li p {
margin-bottom: 0; margin-bottom: 0;
padding: 0; padding: 0;
} }
/* Avoid margins for nested lists */ /* Avoid margins for nested lists */
.markdown-section li > ul { .markdown-section li > ul {
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
} }
/* Table styles */ /* Table styles */
.markdown-section table { .markdown-section table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 1rem 0; margin: 1rem 0;
} }
.markdown-section th, .markdown-section th,
.markdown-section td { .markdown-section td {
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 0.5rem; padding: 0.5rem;
text-align: left; text-align: left;
} }
.markdown-section th { .markdown-section th {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
.markdown-section pre { .markdown-section pre {
padding: 10px; padding: 10px;
margin: 10px; margin: 10px;
} }
.markdown-section pre code { .markdown-section pre code {
position: relative; position: relative;
color: rgb(172, 0, 95); color: rgb(172, 0, 95);
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,9 +4,12 @@ from pathlib import Path
from typing import Dict, Any, List from typing import Dict, Any, List
from markdown import markdown from markdown import markdown
from starlette.responses import Response
from xhtml2pdf import pisa from xhtml2pdf import pisa
import site
from fpdf import FPDF
from open_webui.env import STATIC_DIR, FONTS_DIR
from open_webui.apps.webui.models.chats import ChatTitleMessagesForm from open_webui.apps.webui.models.chats import ChatTitleMessagesForm
@ -30,21 +33,31 @@ class PDFGenerator:
self.html_body = None self.html_body = None
self.messages_html = None self.messages_html = None
self.form_data = form_data self.form_data = form_data
self.css_style_file = Path("./backend/open_webui/static/assets/pdf-style.css")
def build_html_message(self, message: Dict[str, Any]) -> str: self.css = Path(STATIC_DIR / "assets" / "pdf-style.css").read_text()
def format_timestamp(self, timestamp: float) -> str:
"""Convert a UNIX timestamp to a formatted date string."""
try:
date_time = datetime.fromtimestamp(timestamp)
return date_time.strftime("%Y-%m-%d, %H:%M:%S")
except (ValueError, TypeError) as e:
# Log the error if necessary
return ""
def _build_html_message(self, message: Dict[str, Any]) -> str:
"""Build HTML for a single message.""" """Build HTML for a single message."""
role = message.get("role", "user") role = message.get("role", "user")
content = message.get("content", "") content = message.get("content", "")
timestamp = message.get('timestamp') timestamp = message.get("timestamp")
model = message.get('model') if role == 'assistant' else '' model = message.get("model") if role == "assistant" else ""
date_str = self.format_timestamp(timestamp) if timestamp else '' date_str = self.format_timestamp(timestamp) if timestamp else ""
# extends pymdownx extension to convert markdown to html. # extends pymdownx extension to convert markdown to html.
# - https://facelessuser.github.io/pymdown-extensions/usage_notes/ # - https://facelessuser.github.io/pymdown-extensions/usage_notes/
html_content = markdown(content, extensions=['pymdownx.extra']) html_content = markdown(content, extensions=["pymdownx.extra"])
html_message = f""" html_message = f"""
<div class="message"> <div class="message">
@ -62,63 +75,35 @@ class PDFGenerator:
""" """
return html_message return html_message
def create_pdf_from_html(self) -> bytes: def _fetch_resources(self, uri: str, rel: str) -> str:
print(str(STATIC_DIR / uri))
return str(STATIC_DIR / uri)
def _create_pdf_from_html(self) -> bytes:
"""Convert HTML content to PDF and return the bytes.""" """Convert HTML content to PDF and return the bytes."""
pdf_buffer = BytesIO() pdf_buffer = BytesIO()
pisa_status = pisa.CreatePDF(src=self.html_body, dest=pdf_buffer) pisa_status = pisa.CreatePDF(
src=self.html_body.encode("UTF-8"),
dest=pdf_buffer,
encoding="UTF-8",
link_callback=self._fetch_resources,
)
if pisa_status.err: if pisa_status.err:
raise RuntimeError("Error generating PDF") raise RuntimeError("Error generating PDF")
return pdf_buffer.getvalue() return pdf_buffer.getvalue()
def format_timestamp(self, timestamp: float) -> str: def _generate_html_body(self) -> str:
"""Convert a UNIX timestamp to a formatted date string."""
try:
date_time = datetime.fromtimestamp(timestamp)
return date_time.strftime("%Y-%m-%d, %H:%M:%S")
except (ValueError, TypeError) as e:
# Log the error if necessary
return ''
def generate_chat_pdf(self) -> Response:
"""
Generate a PDF from chat messages.
Returns:
A FastAPI Response with the generated PDF or an error message.
"""
try:
# Build HTML messages
messages_html_list: List[str] = [self.build_html_message(msg) for msg in self.form_data.messages]
self.messages_html = '<div>' + ''.join(messages_html_list) + '</div>'
# Generate full HTML body
self.html_body = self.generate_html_body()
# Create PDF
pdf_bytes = self.create_pdf_from_html()
# Return PDF as response
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": "attachment;filename=chat.pdf"},
)
except RuntimeError as pdf_error:
# Handle PDF generation errors
return Response(content=str(pdf_error), status_code=500)
except Exception as e:
# Handle other unexpected errors
return Response(content="An unexpected error occurred.", status_code=500)
def generate_html_body(self) -> str:
"""Generate the full HTML body for the PDF.""" """Generate the full HTML body for the PDF."""
return f""" return f"""
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{self.css_style_file.as_posix()}"> <style type="text/css">
{self.css}
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@ -132,3 +117,57 @@ class PDFGenerator:
</body> </body>
</html> </html>
""" """
def generate_chat_pdf(self) -> bytes:
"""
Generate a PDF from chat messages.
"""
try:
global FONTS_DIR
pdf = FPDF()
pdf.add_page()
# When running using `pip install` the static directory is in the site packages.
if not FONTS_DIR.exists():
FONTS_DIR = Path(site.getsitepackages()[0]) / "static/fonts"
# When running using `pip install -e .` the static directory is in the site packages.
# This path only works if `open-webui serve` is run from the root of this project.
if not FONTS_DIR.exists():
FONTS_DIR = Path("./backend/static/fonts")
pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf")
pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf")
pdf.add_font("NotoSans", "i", f"{FONTS_DIR}/NotoSans-Italic.ttf")
pdf.add_font("NotoSansKR", "", f"{FONTS_DIR}/NotoSansKR-Regular.ttf")
pdf.add_font("NotoSansJP", "", f"{FONTS_DIR}/NotoSansJP-Regular.ttf")
pdf.add_font("NotoSansSC", "", f"{FONTS_DIR}/NotoSansSC-Regular.ttf")
pdf.set_font("NotoSans", size=12)
pdf.set_fallback_fonts(["NotoSansKR", "NotoSansJP", "NotoSansSC"])
pdf.set_auto_page_break(auto=True, margin=15)
# Adjust the effective page width for multi_cell
effective_page_width = (
pdf.w - 2 * pdf.l_margin - 10
) # Subtracted an additional 10 for extra padding
# Add chat messages
for message in self.form_data.messages:
role = message["role"]
content = message["content"]
pdf.set_font("NotoSans", "B", size=14) # Bold for the role
pdf.multi_cell(effective_page_width, 10, f"{role.upper()}", 0, "L")
pdf.ln(1) # Extra space between messages
pdf.set_font("NotoSans", size=10) # Regular for content
pdf.multi_cell(effective_page_width, 6, content, 0, "L")
pdf.ln(1.5) # Extra space between messages
# Save the pdf with name .pdf
pdf_bytes = pdf.output()
return bytes(pdf_bytes)
except Exception as e:
raise e