Skip to content

Commit

Permalink
feat: add PDF export feature
Browse files Browse the repository at this point in the history
  • Loading branch information
ruibaby committed Oct 20, 2024
1 parent 520e10e commit 7feee68
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 26 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
## 特性

- 支持 Markdown 和富文本格式的互转。
- 支持导出文章内容,支持转换为 Markdown 文件导出。
- 支持导出文章内容,支持转换为 Markdown 文件导出,支持导出为 PDF
- 支持文章克隆。
- 支持复制文章内容。

## 使用方式

Expand All @@ -29,4 +30,4 @@

![](./images/plugin-content-tools-preview-4.png)

![](./images/plugin-content-tools-preview-5.png)
![](./images/plugin-content-tools-preview-5.png)
112 changes: 112 additions & 0 deletions ui/src/class/contentExporter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ContentTypes } from '@/constants';
import { downloadContent } from '@/utils';
import { processHTMLLinks } from '@/utils/content';
import { consoleApiClient, type Post } from '@halo-dev/api-client';
import { Toast } from '@halo-dev/components';
import { ConverterFactory } from './converterFactory';

type ExportType = 'original' | 'markdown';
Expand Down Expand Up @@ -32,4 +34,114 @@ export class ContentExporter {

downloadContent(exportContent, post.spec.title, fileExtension);
}

static async exportToPdf(post: Post): Promise<void> {
try {
const { data: content } = await consoleApiClient.content.post.fetchPostHeadContent({
name: post.metadata.name,
});

let htmlContent = content.rawType?.toLowerCase() === 'html' ? content.content : content.raw;

if (content.rawType?.toLowerCase() === 'markdown') {
const converter = ConverterFactory.getConverter('markdown', 'html');
htmlContent = converter.convert(post, content);
}

htmlContent = processHTMLLinks(htmlContent || '');

const iframe = document.createElement('iframe');
iframe.style.position = 'absolute';
iframe.style.left = '-9999px';
iframe.style.top = '-9999px';
document.body.appendChild(iframe);

iframe.contentDocument?.write(`
<html>
<head>
<title>${post.spec.title}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
img { max-width: 100%; height: auto; }
@media print {
body { margin: 0; padding: 20px; }
h1 { page-break-before: always; }
}
</style>
</head>
<body>
<h1>${post.spec.title}</h1>
${htmlContent}
</body>
</html>
`);

iframe.contentDocument?.close();

await this.waitForImages(iframe);

iframe.contentWindow?.print();

setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
} catch (error) {
console.error('Failed to generate pdf file: ', error);
Toast.error('PDF 导出失败');
}
}

private static waitForImages(iframe: HTMLIFrameElement): Promise<void> {
return new Promise((resolve) => {
const images = iframe.contentDocument?.images;
if (!images || images.length === 0) {
resolve();
return;
}

let loadedCount = 0;
const totalCount = images.length;

const checkComplete = () => {
if (loadedCount === totalCount) {
resolve();
}
};

const placeholderImage =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';

Array.from(images).forEach((img) => {
if (img.complete) {
loadedCount++;
checkComplete();
} else {
img.onload = () => {
loadedCount++;
checkComplete();
};
img.onerror = () => {
loadedCount++;
img.src = placeholderImage;
img.alt = '图片加载失败';
img.style.border = '1px solid #ccc';
img.style.padding = '5px';
checkComplete();
};
}
});

setTimeout(() => {
Array.from(images).forEach((img) => {
if (!img.complete) {
img.src = placeholderImage;
img.alt = '图片加载超时';
img.style.border = '1px solid #ccc';
img.style.padding = '5px';
}
});
resolve();
}, 5000);
});
}
}
27 changes: 3 additions & 24 deletions ui/src/class/postContentCopier.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { processHTMLLinks, processMarkdownLinks } from '@/utils/content';
import { copyHtmlAsRichText, copyText } from '@/utils/copy';
import { convertPostContentToMarkdown } from '@/utils/markdown';
import { consoleApiClient, type ContentWrapper, type Post } from '@halo-dev/api-client';
Expand Down Expand Up @@ -60,37 +61,15 @@ class PostContentCopier {
if (!content) {
throw new Error('No content to copy');
}
copyHtmlAsRichText(this.processHTMLLinks(content));
copyHtmlAsRichText(processHTMLLinks(content));
Toast.success('文章内容已复制为富文本格式');
}

private static copyAsMarkdown(markdown: string) {
copyText(this.processMarkdownLinks(markdown));
copyText(processMarkdownLinks(markdown));
Toast.success('文章内容已复制为 Markdown 文本');
}

private static processHTMLLinks(content: string): string {
const htmlLinkRegex = /(src|href)=["'](?!http:\/\/|https:\/\/|mailto:|tel:)([^"']+)["']/gi;
return content.replace(htmlLinkRegex, (_, attr, url) => {
return `${attr}="${this.getAbsoluteUrl(url)}"`;
});
}

private static processMarkdownLinks(content: string): string {
const markdownLinkRegex = /(!?\[.*?\]\()(?!http:\/\/|https:\/\/|mailto:|tel:)([^)]+)(\))/g;
return content.replace(markdownLinkRegex, (_, prefix, url, suffix) => {
return `${prefix}${this.getAbsoluteUrl(url)}${suffix}`;
});
}

private static getAbsoluteUrl(url: string): string {
if (url.startsWith('/')) {
return `${location.origin}${url}`;
} else {
return `${location.origin}/${url}`;
}
}

private static async fetchPostContent(name: string): Promise<ContentWrapper> {
const { data } = await consoleApiClient.content.post.fetchPostHeadContent({
name,
Expand Down
9 changes: 9 additions & 0 deletions ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ export default definePlugin({
ContentExporter.export(post.post, 'markdown');
},
},
{
priority: 2,
component: markRaw(VDropdownItem),
label: '转换为 PDF 并导出',
visible: true,
action: (post: ListedPost) => {
ContentExporter.exportToPdf(post.post);
},
},
],
},
{
Expand Down
21 changes: 21 additions & 0 deletions ui/src/utils/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function processHTMLLinks(content: string): string {
const htmlLinkRegex = /(src|href)=["'](?!http:\/\/|https:\/\/|mailto:|tel:)([^"']+)["']/gi;
return content.replace(htmlLinkRegex, (_, attr, url) => {
return `${attr}="${getAbsoluteUrl(url)}"`;
});
}

export function processMarkdownLinks(content: string): string {
const markdownLinkRegex = /(!?\[.*?\]\()(?!http:\/\/|https:\/\/|mailto:|tel:)([^)]+)(\))/g;
return content.replace(markdownLinkRegex, (_, prefix, url, suffix) => {
return `${prefix}${getAbsoluteUrl(url)}${suffix}`;
});
}

export function getAbsoluteUrl(url: string): string {
if (url.startsWith('/')) {
return `${location.origin}${url}`;
} else {
return `${location.origin}/${url}`;
}
}

0 comments on commit 7feee68

Please sign in to comment.