核心是利用浏览器的print能力
方案一 通过指定dom节点打印
const printSpecificElement = (elementId: string) => {
const element = document.getElementById(elementId);
if (!element) return;
// 创建打印窗口
const printWindow = window.open('', '_blank');
if (!printWindow) {
console.error('无法打开打印窗口');
return;
}
// 获取要打印的元素的HTML内容
const printContent = element.innerHTML;
// 获取当前页面的样式
const styles = [...document.styleSheets]
.map((styleSheet) => {
try {
return [...(styleSheet.cssRules || [])]
.map((rule) => rule.cssText)
.join('');
} catch {
// 跨域样式表无法访问,跳过
return '';
}
})
.join('');
// 写入打印窗口内容
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>打印</title>
<style>${styles}</style>
<style>
@media print {
body {
margin: 0;
padding: 20px;
background-color: #f8f8f8;
}
}
</style>
</head>
<body>
${printContent}
</body>
</html>
`);
printWindow.document.close();
// 等待内容加载完成后打印
printWindow.addEventListener('load', () => {
printWindow.focus();
printWindow.print();
printWindow.close();
});
};
方案二 通过渲染vue组件打印
- report-cover.vue
<script setup lang="ts">
import { onMounted } from 'vue';
defineOptions({ name: 'CodeCard', inheritAttrs: false });
defineProps<{
meetingId: number;
reportId: number;
}>();
const initData = async () => {
setTimeout(() => {
// 合适的时机调用,比如接口返回数据后, 通知打印准备完毕
window.top?.postMessage('print-done');
}, 200);
};
onMounted(() => {
initData();
});
</script>
<template>
<div>test-content</div>
</template>
<style scoped lang="scss">
@media print {
.page-break {
page-break-after: always;
}
html,
body {
height: auto !important;
overflow: auto !important;
}
.report-cover-wrap {
min-height: 100vh !important;
background: #fff;
background-color: #fff;
}
}
</style>
- 打印逻辑
/** 打印会议报告 */
const handlePrint = async () => {
let vueInstance: App<Element> | null = null;
const myDocument = document;
const PromiseProxy: Record<string, any> = {
promise: Promise.resolve(),
resolve: null,
reject: null,
};
PromiseProxy.promise = new Promise((resolve, reject) => {
PromiseProxy.resolve = resolve;
PromiseProxy.reject = reject;
});
function setPrint(iframe: HTMLIFrameElement) {
// 获取iframe的document对象
const iframeDocument =
iframe.contentDocument || iframe.contentWindow?.document;
const contentWindow = iframe.contentWindow!;
copyStylesToIframe(myDocument, iframe);
const rootDiv = iframeDocument?.createElement('div') as HTMLDivElement;
vueInstance = createApp({
render: () =>
h(ReportCover, {
meetingId,
reportId: reportId.value!,
}),
});
vueInstance.mount(rootDiv);
iframeDocument?.body.append(rootDiv);
const listener = (event: any) => {
if (event.data === eventPrintType) {
// 执行某些操作,例如关闭预览或更新UI
contentWindow.print();
PromiseProxy.resolve();
}
};
const closePrint = () => {
window.removeEventListener('message', listener);
iframe.remove();
vueInstance?.unmount();
};
contentWindow.addEventListener('afterprint', closePrint);
// eslint-disable-next-line unicorn/prefer-add-event-listener
contentWindow.onbeforeunload = closePrint;
window.addEventListener('message', listener);
}
const hideFrame = document.createElement('iframe');
hideFrame.addEventListener('load', () => setPrint(hideFrame));
hideFrame.style.display = 'none'; // 隐藏 iframe
hideFrame.style.position = 'fixed';
hideFrame.style.overflow = 'hidden';
hideFrame.style.zIndex = '9999';
document.body.append(hideFrame);
return PromiseProxy.promise;
};
方案三 snapdom 或者其他 三方npm库,自行探索
www.npmjs.com/package/@zu… 可以方便实现图片打印下载等
- snapdom + jspdf (图片式打印,无法二次编辑)
import { snapdom } from '@zumer/snapdom';
import jsPDF from 'jspdf';
export const downloadPDF = async (
el: HTMLElement,
fileName = 'report',
resolveFn?: (data: any) => void,
) => {
const Img = await snapdom.toImg(el, {
noShadows: true,
});
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 分别设置四个方向的边距
const margins = {
top: 20,
right: 20,
bottom: 20,
left: 20,
};
// 获取设备像素比
const pixelRatio = window.devicePixelRatio || 1;
// 按像素比调整canvas尺寸,并加上边距
canvas.width = (Img.width + margins.left + margins.right) * pixelRatio;
canvas.height = (Img.height + margins.top + margins.bottom) * pixelRatio;
// 缩放canvas上下文以适应高分辨率
if (ctx) {
ctx.scale(pixelRatio, pixelRatio);
}
// 设置canvas CSS尺寸保持原始大小(包含边距)
canvas.style.width = `${Img.width + margins.left + margins.right}px`;
canvas.style.height = `${Img.height + margins.top + margins.bottom}px`;
if (ctx) {
// 先绘制背景色
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(
0,
0,
Img.width + margins.left + margins.right,
Img.height + margins.top + margins.bottom,
);
}
// 绘制图片时设置边距
ctx?.drawImage(Img, margins.left, margins.top, Img.width, Img.height);
const contentWidth = canvas.width;
const contentHeight = canvas.height;
// 一页pdf显示html页面生成的canvas高度;
const pageHeight = (contentWidth / 592.28) * 841.89;
// 未生成pdf的html页面高度
let leftHeight = contentHeight;
// 页面偏移
let position = 0;
// a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
const imgWidth = 595.28;
const imgHeight = (592.28 / contentWidth) * contentHeight;
const pageData = canvas.toDataURL('image/jpeg', 1);
const img = document.createElement('img');
img.src = pageData;
document.body.append(img);
// eslint-disable-next-line new-cap
const pdf = new jsPDF({
orientation: 'p',
unit: 'pt',
format: 'a4',
});
// 有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
// 当内容未超过pdf一页显示的范围,无需分页
if (leftHeight < pageHeight) {
pdf.addImage({
imageData: pageData,
format: 'JPEG',
x: 0,
y: 0,
width: imgWidth,
height: imgHeight,
});
} else {
while (leftHeight > 0) {
pdf.addImage({
imageData: pageData,
format: 'JPEG',
x: 0,
y: position,
width: imgWidth,
height: imgHeight,
});
leftHeight -= pageHeight;
position = position - 841.89;
// 避免添加空白页
if (leftHeight > 0) {
pdf.addPage();
// pdf.setFillColor(255, 255, 255); // 白色背景
// pdf.rect(0, 0, 595.28, 842.89, 'F'); // 绘制背景矩形
}
}
}
pdf.save(`${fileName}.pdf`);
resolveFn?.({});
};
- html2canvas + jsPdf
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
const optimizedHighQualityPrint = async () => {
const el = document.querySelector('#bysking');
const canvas = await html2canvas(el, {
logging: false,
useCORS: true,
scale: 2, // 3倍分辨率
// allowTaint: true,
// foreignObjectRendering: true,
// imageTimeout: 0,
// removeContainer: true,
// backgroundColor: '#ffffff',
});
const contentWidth = canvas.width;
const contentHeight = canvas.height;
// 一页pdf显示html页面生成的canvas高度;
const pageHeight = (contentWidth / 592.28) * 841.89;
// 未生成pdf的html页面高度
let leftHeight = contentHeight;
// 页面偏移
let position = 0;
// a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
const imgWidth = 595.28;
const imgHeight = (592.28 / contentWidth) * contentHeight;
const pageData = canvas.toDataURL('image/jpeg', 1);
// eslint-disable-next-line new-cap
const pdf = new jsPDF({
orientation: 'p',
unit: 'pt',
format: 'a4',
});
// 有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
// 当内容未超过pdf一页显示的范围,无需分页
if (leftHeight < pageHeight) {
pdf.addImage(pageData, 'JPEG', 0, 0, imgWidth, imgHeight);
} else {
while (leftHeight > 0) {
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight);
leftHeight -= pageHeight;
position = position - 841.89;
// 避免添加空白页
if (leftHeight > 0) {
pdf.addPage();
}
}
}
pdf.save('content.pdf');
};
方案四 nodejs+puppeteer 实现后端打印
- 后端核心代码
const puppeteer = require('puppeteer');
const fs = require('fs').promises;
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 读取HTML文件内容得到html字符串
let htmlContent = await fs.readFile('./test.html', 'utf8');
// 设置处理后的内容到页面
await page.setContent(htmlContent, { waitUntil: 'domcontentloaded' });
await page.pdf({ path: 'example.pdf', format: 'A4', preferCSSPageSize: true, printBackground: true, margin: { top: '10px', right: '10px', bottom: '10px', left: '10px' } });
await browser.close();
console.log('PDF generated successfully with base64 images.');
})();
- 前端核心代码
// 将图片URL转换为base64
const convertImagesToBase64 = async (htmlString: string): Promise<string> => {
// 创建一个临时的DOM元素来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;
// 查找所有img元素
const images = tempDiv.querySelectorAll('img');
// 创建所有图片转换的Promise数组
const promises = [...images].map(async (img) => {
const src = img.src;
// 如果已经是base64或者没有src,则跳过
if (!src || src.startsWith('data:')) {
return;
}
try {
// 将图片URL转换为base64
const base64 = await urlToBase64(src);
img.src = base64;
} catch (error) {
console.warn(`转换图片失败: ${src}`, error);
// 转换失败时保留原始src
}
});
// 等待所有图片转换完成
await Promise.all(promises);
// 返回转换后的HTML字符串
return tempDiv.innerHTML;
};
// 将URL转换为base64
const urlToBase64 = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
// 创建一个新的图片对象
const img = new Image();
// 处理跨域问题
img.crossOrigin = 'anonymous';
img.addEventListener('load', () => {
try {
// 创建canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
// 设置canvas尺寸
canvas.width = img.width;
canvas.height = img.height;
// 绘制图片到canvas
ctx.drawImage(img, 0, 0);
// 获取图片的MIME类型
const mimeType = getMimeTypeFromUrl(url);
// 转换为base64
const dataURL = canvas.toDataURL(mimeType);
resolve(dataURL);
} catch (error) {
reject(error);
}
});
img.onerror = (error) => {
reject(new Error(`图片加载失败: ${url}`));
};
// 开始加载图片
img.src = url;
});
};
// 根据URL获取MIME类型
const getMimeTypeFromUrl = (url: string): string => {
// 从URL中提取文件扩展名
const match = url.match(/\.([^.?#]+)(?:[?#]|$)/);
const extension = match ? match[1].toLowerCase() : '';
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
bmp: 'image/bmp',
};
return mimeTypes[extension] || 'image/png';
};
// 移除包含指定类名的元素(完整版本)
const removeIgnoreElement = (htmlString: string, className: string): string => {
try {
// 创建一个临时的DOM元素来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;
// 使用多种方式查找包含指定类名的元素
// 1. 直接匹配类名
let ignoreElements = tempDiv.querySelectorAll(`.${className}`);
// 2. 处理可能有多个类名的情况
const allElements = tempDiv.querySelectorAll('*');
allElements.forEach((el) => {
if (
el.classList &&
el.classList.contains(className) && // 避免重复添加到ignoreElements中
![...ignoreElements].includes(el)
) {
ignoreElements = [
...ignoreElements,
el,
] as unknown as NodeListOf<Element>;
}
});
// 从后往前移除元素,避免在遍历时影响DOM结构
const elementsArray = [...ignoreElements];
for (let i = elementsArray.length - 1; i >= 0; i--) {
elementsArray[i].remove();
}
// 返回处理后的HTML字符串
return tempDiv.innerHTML;
} catch (error) {
console.error('移除忽略元素时出错:', error);
// 出错时返回原始HTML字符串
return htmlString;
}
};
const printSpecificElement = async (elementId: string) => {
const element = document.getElementById(elementId);
if (!element) return;
// 获取要打印的元素的HTML内容
let printContent = element.innerHTML;
// 获取当前页面的样式
const styles = [...document.styleSheets]
.map((styleSheet) => {
try {
return [...(styleSheet.cssRules || [])]
.map((rule) => rule.cssText)
.join('');
} catch {
// 跨域样式表无法访问,跳过
return '';
}
})
.join('');
// 转换所有图片链接为base64
printContent = await convertImagesToBase64(printContent);
printContent = removeIgnoreElement(printContent, 'print-ignore');
return `
<!DOCTYPE html>
<html>
<head>
<title>打印</title>
<style>
body {
font-family: Arial, sans-serif;
}
.page {
page-break-after: always;
}
.page:last-child {
page-break-after: avoid;
}
.section {
margin-bottom: 20px;
}
.no-break {
page-break-inside: avoid;
}
@media print {
.page-break {
page-break-after: always;
}
.avoid-break {
page-break-inside: avoid;
}
}
${styles}
</style>
<style>
@media print {
body {
margin: 0;
padding: 20px;
background-color: #f8f8f8;
}
}
</style>
</head>
<body>
${printContent}
</body>
</html>
`;
};
- 前端调用代码
const testPrint = async () => {
const htmlStr = await printSpecificElement('bysking');
console.log(htmlStr, 'htmlStr'); // 前端获取需要打印的节点的html字符串,包含图片远程src资源地址转化
// 然后用这个字符串请求后端服务进行打印
// 后端生成的pdf以二进制返回
};
有帮助,点个关注,来个赞!!