mirror of
https://git.mirrors.martin98.com/https://github.com/open-webui/open-webui
synced 2025-08-15 15:35:55 +08:00
feat: unstylized pdf export
This commit is contained in:
parent
a515a5df1a
commit
3a79840a87
@ -54,6 +54,9 @@
|
|||||||
height: ''
|
height: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// chat export
|
||||||
|
let stylizedPdfExport = true;
|
||||||
|
|
||||||
// Admin - Show Update Available Toast
|
// Admin - Show Update Available Toast
|
||||||
let showUpdateToast = true;
|
let showUpdateToast = true;
|
||||||
let showChangelog = true;
|
let showChangelog = true;
|
||||||
@ -152,6 +155,11 @@
|
|||||||
saveSettings({ hapticFeedback: hapticFeedback });
|
saveSettings({ hapticFeedback: hapticFeedback });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleStylizedPdfExport = async () => {
|
||||||
|
stylizedPdfExport = !stylizedPdfExport;
|
||||||
|
saveSettings({ stylizedPdfExport: stylizedPdfExport });
|
||||||
|
};
|
||||||
|
|
||||||
const toggleUserLocation = async () => {
|
const toggleUserLocation = async () => {
|
||||||
userLocation = !userLocation;
|
userLocation = !userLocation;
|
||||||
|
|
||||||
@ -302,6 +310,11 @@
|
|||||||
notificationSound = $settings?.notificationSound ?? true;
|
notificationSound = $settings?.notificationSound ?? true;
|
||||||
notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
|
notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
|
||||||
|
|
||||||
|
iframeSandboxAllowSameOrigin = $settings?.iframeSandboxAllowSameOrigin ?? false;
|
||||||
|
iframeSandboxAllowForms = $settings?.iframeSandboxAllowForms ?? false;
|
||||||
|
|
||||||
|
stylizedPdfExport = $settings?.stylizedPdfExport ?? true;
|
||||||
|
|
||||||
hapticFeedback = $settings.hapticFeedback ?? false;
|
hapticFeedback = $settings.hapticFeedback ?? false;
|
||||||
ctrlEnterToSend = $settings.ctrlEnterToSend ?? false;
|
ctrlEnterToSend = $settings.ctrlEnterToSend ?? false;
|
||||||
|
|
||||||
@ -964,6 +977,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class=" py-0.5 flex w-full justify-between">
|
||||||
|
<div class=" self-center text-xs">
|
||||||
|
{$i18n.t('Stylized PDF Export')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="p-1 px-3 text-xs flex rounded-sm transition"
|
||||||
|
on:click={() => {
|
||||||
|
toggleStylizedPdfExport();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if stylizedPdfExport === true}
|
||||||
|
<span class="ml-2 self-center">{$i18n.t('On')}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class=" my-1.5 text-sm font-medium">{$i18n.t('Voice')}</div>
|
<div class=" my-1.5 text-sm font-medium">{$i18n.t('Voice')}</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -19,7 +19,8 @@
|
|||||||
mobile,
|
mobile,
|
||||||
temporaryChatEnabled,
|
temporaryChatEnabled,
|
||||||
theme,
|
theme,
|
||||||
user
|
user,
|
||||||
|
settings
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
import { flyAndScale } from '$lib/utils/transitions';
|
import { flyAndScale } from '$lib/utils/transitions';
|
||||||
|
|
||||||
@ -63,76 +64,124 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadPdf = async () => {
|
const downloadPdf = async () => {
|
||||||
const containerElement = document.getElementById('messages-container');
|
if ($settings?.stylizedPdfExport ?? true) {
|
||||||
|
const containerElement = document.getElementById('messages-container');
|
||||||
|
|
||||||
if (containerElement) {
|
if (containerElement) {
|
||||||
try {
|
try {
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||||
const virtualWidth = 800; // Fixed width in px
|
const virtualWidth = 800; // Fixed width in px
|
||||||
const pagePixelHeight = 1200; // Each slice height (adjust to avoid canvas bugs; generally 2–4k is safe)
|
const pagePixelHeight = 1200; // Each slice height (adjust to avoid canvas bugs; generally 2–4k is safe)
|
||||||
|
|
||||||
// Clone & style once
|
// Clone & style once
|
||||||
const clonedElement = containerElement.cloneNode(true);
|
const clonedElement = containerElement.cloneNode(true);
|
||||||
clonedElement.classList.add('text-black');
|
clonedElement.classList.add('text-black');
|
||||||
clonedElement.classList.add('dark:text-white');
|
clonedElement.classList.add('dark:text-white');
|
||||||
clonedElement.style.width = `${virtualWidth}px`;
|
clonedElement.style.width = `${virtualWidth}px`;
|
||||||
clonedElement.style.position = 'absolute';
|
clonedElement.style.position = 'absolute';
|
||||||
clonedElement.style.left = '-9999px'; // Offscreen
|
clonedElement.style.left = '-9999px'; // Offscreen
|
||||||
clonedElement.style.height = 'auto';
|
clonedElement.style.height = 'auto';
|
||||||
document.body.appendChild(clonedElement);
|
document.body.appendChild(clonedElement);
|
||||||
|
|
||||||
// Get total height after attached to DOM
|
// Get total height after attached to DOM
|
||||||
const totalHeight = clonedElement.scrollHeight;
|
const totalHeight = clonedElement.scrollHeight;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
let page = 0;
|
let page = 0;
|
||||||
|
|
||||||
// Prepare PDF
|
// Prepare PDF
|
||||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||||
const imgWidth = 210; // A4 mm
|
const imgWidth = 210; // A4 mm
|
||||||
const pageHeight = 297; // A4 mm
|
const pageHeight = 297; // A4 mm
|
||||||
|
|
||||||
while (offsetY < totalHeight) {
|
while (offsetY < totalHeight) {
|
||||||
// For each slice, adjust scrollTop to show desired part
|
// For each slice, adjust scrollTop to show desired part
|
||||||
clonedElement.scrollTop = offsetY;
|
clonedElement.scrollTop = offsetY;
|
||||||
|
|
||||||
// Optionally: mask/hide overflowing content via CSS if needed
|
// Optionally: mask/hide overflowing content via CSS if needed
|
||||||
clonedElement.style.maxHeight = `${pagePixelHeight}px`;
|
clonedElement.style.maxHeight = `${pagePixelHeight}px`;
|
||||||
// Only render the visible part
|
// Only render the visible part
|
||||||
const canvas = await html2canvas(clonedElement, {
|
const canvas = await html2canvas(clonedElement, {
|
||||||
backgroundColor: isDarkMode ? '#000' : '#fff',
|
backgroundColor: isDarkMode ? '#000' : '#fff',
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
width: virtualWidth,
|
width: virtualWidth,
|
||||||
height: Math.min(pagePixelHeight, totalHeight - offsetY),
|
height: Math.min(pagePixelHeight, totalHeight - offsetY),
|
||||||
// Optionally: y offset for correct region?
|
// Optionally: y offset for correct region?
|
||||||
windowWidth: virtualWidth
|
windowWidth: virtualWidth
|
||||||
//windowHeight: pagePixelHeight,
|
//windowHeight: pagePixelHeight,
|
||||||
});
|
});
|
||||||
const imgData = canvas.toDataURL('image/png');
|
const imgData = canvas.toDataURL('image/png');
|
||||||
// Maintain aspect ratio
|
// Maintain aspect ratio
|
||||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||||
const position = 0; // Always first line, since we've clipped vertically
|
const position = 0; // Always first line, since we've clipped vertically
|
||||||
|
|
||||||
if (page > 0) pdf.addPage();
|
if (page > 0) pdf.addPage();
|
||||||
|
|
||||||
// Set page background for dark mode
|
// Set page background for dark mode
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
pdf.setFillColor(0, 0, 0);
|
pdf.setFillColor(0, 0, 0);
|
||||||
pdf.rect(0, 0, imgWidth, pageHeight, 'F'); // black bg
|
pdf.rect(0, 0, imgWidth, pageHeight, 'F'); // black bg
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||||
|
|
||||||
|
offsetY += pagePixelHeight;
|
||||||
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
document.body.removeChild(clonedElement);
|
||||||
|
|
||||||
offsetY += pagePixelHeight;
|
pdf.save(`chat-${chat.chat.title}.pdf`);
|
||||||
page++;
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.removeChild(clonedElement);
|
|
||||||
|
|
||||||
pdf.save(`chat-${chat.chat.title}.pdf`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating PDF', error);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Downloading PDF');
|
||||||
|
|
||||||
|
const chatText = await getChatAsText();
|
||||||
|
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Margins
|
||||||
|
const left = 15;
|
||||||
|
const top = 20;
|
||||||
|
const right = 15;
|
||||||
|
const bottom = 20;
|
||||||
|
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
const usableWidth = pageWidth - left - right;
|
||||||
|
const usableHeight = pageHeight - top - bottom;
|
||||||
|
|
||||||
|
// Font size and line height
|
||||||
|
const fontSize = 8;
|
||||||
|
doc.setFontSize(fontSize);
|
||||||
|
const lineHeight = fontSize * 1; // adjust if needed
|
||||||
|
|
||||||
|
// Split the markdown into lines (handles \n)
|
||||||
|
const paragraphs = chatText.split('\n');
|
||||||
|
|
||||||
|
let y = top;
|
||||||
|
|
||||||
|
for (let paragraph of paragraphs) {
|
||||||
|
// Wrap each paragraph to fit the width
|
||||||
|
const lines = doc.splitTextToSize(paragraph, usableWidth);
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
// If the line would overflow the bottom, add a new page
|
||||||
|
if (y + lineHeight > pageHeight - bottom) {
|
||||||
|
doc.addPage();
|
||||||
|
y = top;
|
||||||
|
}
|
||||||
|
doc.text(line, left, y);
|
||||||
|
y += lineHeight * 0.5;
|
||||||
|
}
|
||||||
|
// Add empty line at paragraph breaks
|
||||||
|
y += lineHeight * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(`chat-${chat.chat.title}.pdf`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
getChatPinnedStatusById,
|
getChatPinnedStatusById,
|
||||||
toggleChatPinnedStatusById
|
toggleChatPinnedStatusById
|
||||||
} from '$lib/apis/chats';
|
} from '$lib/apis/chats';
|
||||||
import { chats, theme, user } from '$lib/stores';
|
import { chats, settings, theme, user } from '$lib/stores';
|
||||||
import { createMessagesList } from '$lib/utils';
|
import { createMessagesList } from '$lib/utils';
|
||||||
import { downloadChatAsPDF } from '$lib/apis/utils';
|
import { downloadChatAsPDF } from '$lib/apis/utils';
|
||||||
import Download from '$lib/components/icons/Download.svelte';
|
import Download from '$lib/components/icons/Download.svelte';
|
||||||
@ -81,76 +81,124 @@
|
|||||||
const downloadPdf = async () => {
|
const downloadPdf = async () => {
|
||||||
const chat = await getChatById(localStorage.token, chatId);
|
const chat = await getChatById(localStorage.token, chatId);
|
||||||
|
|
||||||
const containerElement = document.getElementById('messages-container');
|
if ($settings?.stylizedPdfExport ?? true) {
|
||||||
|
const containerElement = document.getElementById('messages-container');
|
||||||
|
|
||||||
if (containerElement) {
|
if (containerElement) {
|
||||||
try {
|
try {
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||||
const virtualWidth = 800; // Fixed width in px
|
const virtualWidth = 800; // Fixed width in px
|
||||||
const pagePixelHeight = 1200; // Each slice height (adjust to avoid canvas bugs; generally 2–4k is safe)
|
const pagePixelHeight = 1200; // Each slice height (adjust to avoid canvas bugs; generally 2–4k is safe)
|
||||||
|
|
||||||
// Clone & style once
|
// Clone & style once
|
||||||
const clonedElement = containerElement.cloneNode(true);
|
const clonedElement = containerElement.cloneNode(true);
|
||||||
clonedElement.classList.add('text-black');
|
clonedElement.classList.add('text-black');
|
||||||
clonedElement.classList.add('dark:text-white');
|
clonedElement.classList.add('dark:text-white');
|
||||||
clonedElement.style.width = `${virtualWidth}px`;
|
clonedElement.style.width = `${virtualWidth}px`;
|
||||||
clonedElement.style.position = 'absolute';
|
clonedElement.style.position = 'absolute';
|
||||||
clonedElement.style.left = '-9999px'; // Offscreen
|
clonedElement.style.left = '-9999px'; // Offscreen
|
||||||
clonedElement.style.height = 'auto';
|
clonedElement.style.height = 'auto';
|
||||||
document.body.appendChild(clonedElement);
|
document.body.appendChild(clonedElement);
|
||||||
|
|
||||||
// Get total height after attached to DOM
|
// Get total height after attached to DOM
|
||||||
const totalHeight = clonedElement.scrollHeight;
|
const totalHeight = clonedElement.scrollHeight;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
let page = 0;
|
let page = 0;
|
||||||
|
|
||||||
// Prepare PDF
|
// Prepare PDF
|
||||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||||
const imgWidth = 210; // A4 mm
|
const imgWidth = 210; // A4 mm
|
||||||
const pageHeight = 297; // A4 mm
|
const pageHeight = 297; // A4 mm
|
||||||
|
|
||||||
while (offsetY < totalHeight) {
|
while (offsetY < totalHeight) {
|
||||||
// For each slice, adjust scrollTop to show desired part
|
// For each slice, adjust scrollTop to show desired part
|
||||||
clonedElement.scrollTop = offsetY;
|
clonedElement.scrollTop = offsetY;
|
||||||
|
|
||||||
// Optionally: mask/hide overflowing content via CSS if needed
|
// Optionally: mask/hide overflowing content via CSS if needed
|
||||||
clonedElement.style.maxHeight = `${pagePixelHeight}px`;
|
clonedElement.style.maxHeight = `${pagePixelHeight}px`;
|
||||||
// Only render the visible part
|
// Only render the visible part
|
||||||
const canvas = await html2canvas(clonedElement, {
|
const canvas = await html2canvas(clonedElement, {
|
||||||
backgroundColor: isDarkMode ? '#000' : '#fff',
|
backgroundColor: isDarkMode ? '#000' : '#fff',
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
width: virtualWidth,
|
width: virtualWidth,
|
||||||
height: Math.min(pagePixelHeight, totalHeight - offsetY),
|
height: Math.min(pagePixelHeight, totalHeight - offsetY),
|
||||||
// Optionally: y offset for correct region?
|
// Optionally: y offset for correct region?
|
||||||
windowWidth: virtualWidth
|
windowWidth: virtualWidth
|
||||||
//windowHeight: pagePixelHeight,
|
//windowHeight: pagePixelHeight,
|
||||||
});
|
});
|
||||||
const imgData = canvas.toDataURL('image/png');
|
const imgData = canvas.toDataURL('image/png');
|
||||||
// Maintain aspect ratio
|
// Maintain aspect ratio
|
||||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||||
const position = 0; // Always first line, since we've clipped vertically
|
const position = 0; // Always first line, since we've clipped vertically
|
||||||
|
|
||||||
if (page > 0) pdf.addPage();
|
if (page > 0) pdf.addPage();
|
||||||
|
|
||||||
// Set page background for dark mode
|
// Set page background for dark mode
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
pdf.setFillColor(0, 0, 0);
|
pdf.setFillColor(0, 0, 0);
|
||||||
pdf.rect(0, 0, imgWidth, pageHeight, 'F'); // black bg
|
pdf.rect(0, 0, imgWidth, pageHeight, 'F'); // black bg
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||||
|
|
||||||
|
offsetY += pagePixelHeight;
|
||||||
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
document.body.removeChild(clonedElement);
|
||||||
|
|
||||||
offsetY += pagePixelHeight;
|
pdf.save(`chat-${chat.chat.title}.pdf`);
|
||||||
page++;
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.removeChild(clonedElement);
|
|
||||||
|
|
||||||
pdf.save(`chat-${chat.chat.title}.pdf`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating PDF', error);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Downloading PDF');
|
||||||
|
|
||||||
|
const chatText = await getChatAsText(chat);
|
||||||
|
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Margins
|
||||||
|
const left = 15;
|
||||||
|
const top = 20;
|
||||||
|
const right = 15;
|
||||||
|
const bottom = 20;
|
||||||
|
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
const usableWidth = pageWidth - left - right;
|
||||||
|
const usableHeight = pageHeight - top - bottom;
|
||||||
|
|
||||||
|
// Font size and line height
|
||||||
|
const fontSize = 8;
|
||||||
|
doc.setFontSize(fontSize);
|
||||||
|
const lineHeight = fontSize * 1; // adjust if needed
|
||||||
|
|
||||||
|
// Split the markdown into lines (handles \n)
|
||||||
|
const paragraphs = chatText.split('\n');
|
||||||
|
|
||||||
|
let y = top;
|
||||||
|
|
||||||
|
for (let paragraph of paragraphs) {
|
||||||
|
// Wrap each paragraph to fit the width
|
||||||
|
const lines = doc.splitTextToSize(paragraph, usableWidth);
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
// If the line would overflow the bottom, add a new page
|
||||||
|
if (y + lineHeight > pageHeight - bottom) {
|
||||||
|
doc.addPage();
|
||||||
|
y = top;
|
||||||
|
}
|
||||||
|
doc.text(line, left, y);
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
|
// Add empty line at paragraph breaks
|
||||||
|
y += lineHeight * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(`chat-${chat.chat.title}.pdf`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user