前言
之前读到大佬写的文章:juejin.cn/post/757660... ,就是没解决文字切割的问题,这边我提供一种思路,不过不是很完美呀!
代码
js
// 尺寸常量
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const PDF_MARGIN_MM = 10;
const PDF_CONTENT_WIDTH_MM = A4_WIDTH_MM - PDF_MARGIN_MM * 2; // 190mm
const PDF_CONTENT_HEIGHT_MM = A4_HEIGHT_MM - PDF_MARGIN_MM * 2; // 277mm
// 1mm = 3.7795275590551 像素(96 DPI)
const MM_TO_PX = 3.7795275590551;
// 图片质量配置
const IMAGE_QUALITY = 0.95;
const IMAGE_FORMAT = "image/png";
import { snapdom } from "@zumer/snapdom";
import { jsPDF } from "jspdf";
import { getNoBlankHeight, listSum } from "./index";
/**
* 将 DOM 元素转换为图片
*/
export async function captureElementToImage(element, quality = IMAGE_QUALITY) {
return new Promise(async (resolve) => {
console.log("开始截图...");
// 保存原始样式
const originalOverflow = element.style.overflow;
const originalHeight = element.style.height;
const originalMaxHeight = element.style.maxHeight;
// 临时设置样式,确保完整截图
element.style.overflow = "visible";
element.style.height = "auto";
element.style.maxHeight = "none";
try {
// 核心:使用 snapdom 进行截图
const capture = await snapdom(element, {
scale: 2, // 2倍清晰度
quality: quality,
});
// // 优先使用 toPng()
// const imgElement = await capture.toPng();
// const dataUrl = imgElement.src;
// let dataUrl = "";
// 验证数据有效性
// if (!dataUrl || dataUrl.length < 100) {
// console.log("toPng 返回无效,尝试 toCanvas...");
const canvas = await capture.toCanvas();
canvas.toBlob((blob) => {
const pageDataUrl = URL.createObjectURL(blob);
console.log("toCanvas 成功,dataUrl:", pageDataUrl);
resolve({ url: pageDataUrl, size: blob.size });
});
// return canvas.toDataURL(IMAGE_FORMAT, quality);
// }
// console.log("截图成功,大小:", (dataUrl.length / 1024).toFixed(2), "KB");
// return dataUrl;
} finally {
// 恢复原始样式
element.style.overflow = originalOverflow;
element.style.height = originalHeight;
element.style.maxHeight = originalMaxHeight;
}
});
}
export async function splitImageIntoPages(imageDataUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = async () => {
const pages = [];
const originalWidth = img.width;
const originalHeight = img.height;
// 将 A4 内容区域转换为像素(考虑 scale=2)
const pageContentHeightPx = Math.floor(
PDF_CONTENT_HEIGHT_MM * MM_TO_PX * 2 // scale=2
);
const pageContentWidthPx = Math.floor(
PDF_CONTENT_WIDTH_MM * MM_TO_PX * 2
);
// 计算缩放比例(图片宽度适配页面宽度)
const widthScale = pageContentWidthPx / originalWidth;
const scaledHeight = originalHeight * widthScale;
console.log(`原始尺寸: ${originalWidth}x${originalHeight}px`);
console.log(`缩放后高度: ${scaledHeight}px`);
if (scaledHeight <= pageContentHeightPx) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = pageContentWidthPx;
canvas.height = scaledHeight;
// 高质量渲染
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
// 绘制当前页内容
ctx.drawImage(
img,
0,
0, // 源图片起始位置
originalWidth,
originalHeight, // 源图片尺寸
0,
0, // 目标起始位置
pageContentWidthPx,
scaledHeight // 目标尺寸
);
// 转换为 data URL
const pageDataUrl = canvas.toDataURL(IMAGE_FORMAT, IMAGE_QUALITY);
pages.push({
dataUrl: pageDataUrl,
width: pageContentWidthPx,
height: scaledHeight,
});
} else {
let img_needHandleHeight = originalHeight;
let pageNum = 1;
let heights = [];
while (img_needHandleHeight > 0) {
// 单次处理的图片高度
const onceHandleImgHeight = pageContentHeightPx / widthScale;
let lineY =
onceHandleImgHeight * pageNum - listSum(heights, pageNum - 2);
// 测试看下分割高度
const height = await getNoBlankHeight(
40,
[255, 255, 255],
img,
lineY,
originalWidth,
2
);
console.log(height, "---height");
heights.push(height);
// 创建新 Canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
canvas.width = pageContentWidthPx;
let currentPageHeight = onceHandleImgHeight - height;
let height_canvas = currentPageHeight * widthScale;
canvas.height = height_canvas;
// 绘制当前页内容
ctx.drawImage(
img,
0,
((pageNum - 1) * pageContentHeightPx) / widthScale -
listSum(heights, pageNum - 2), // 源图片起始位置
originalWidth,
currentPageHeight, // 源图片尺寸
0,
0, // 目标起始位置
pageContentWidthPx,
height_canvas // 目标尺寸
);
console.log(`第 ${pageNum} 页处理完成`);
// 转换为 data URL
const pageDataUrl = canvas.toDataURL(IMAGE_FORMAT, IMAGE_QUALITY);
pages.push({
dataUrl: pageDataUrl,
width: pageContentWidthPx,
height: height_canvas,
});
pageNum++;
img_needHandleHeight -= currentPageHeight;
console.log(img_needHandleHeight, "---剩下的多少");
// 解决最后一页没有内容
if (img_needHandleHeight < 1) {
img_needHandleHeight = 0;
}
}
}
resolve(pages);
};
img.onerror = () => reject(new Error("图片加载失败"));
img.src = imageDataUrl;
});
}
/**
* 从分页图片创建 PDF
*/
export function createPdfFromPages(pages) {
const pdf = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4",
compress: true, // 启用压缩,减小文件体积
});
if (pages.length === 0) {
throw new Error("没有可添加的页面");
}
pages.forEach((page, index) => {
// 第一页直接用,后续需要 addPage
if (index > 0) {
pdf.addPage();
}
// 像素转毫米(考虑 scale=2)
const scaleFactor = 2;
const pageHeightMm = page.height / MM_TO_PX / scaleFactor;
// 图片适配内容区域宽度
const finalWidth = PDF_CONTENT_WIDTH_MM; // 190mm
const finalHeight = pageHeightMm;
// 位置:左上角对齐,保留 10mm 边距
const x = PDF_MARGIN_MM;
const y = PDF_MARGIN_MM;
console.log(
`添加第 ${index + 1} 页: ${finalWidth}x${finalHeight.toFixed(2)}mm`
);
// 添加图片到 PDF
pdf.addImage(page.dataUrl, "PNG", x, y, finalWidth, finalHeight);
});
return pdf;
}
/**
* 主导出函数
*/
export async function exportMessagesToPdf(config) {
const {
targetSelector,
filename = "messages.pdf",
quality = IMAGE_QUALITY,
} = config;
console.log("=== 开始导出 PDF ===");
// 1. 获取目标元素
const element = document.querySelector(targetSelector);
if (!element) {
throw new Error(`元素未找到: ${targetSelector}`);
}
console.log("元素尺寸:", {
width: element.offsetWidth,
height: element.scrollHeight,
});
// 2. DOM 截图
const { url, size } = await captureElementToImage(element, quality);
console.log("截图完成,大小:", (size / 1024).toFixed(2), "KB");
// 3. 图片分页
const pages = await splitImageIntoPages(url);
console.log(`分页完成,共 ${pages.length} 页`);
// 4. 创建 PDF
const pdf = createPdfFromPages(pages);
// 5. 保存文件
pdf.save(filename);
console.log("=== 导出完成 ===");
}
export async function isImageBlank(
rgb = [],
img,
sourceStartY,
originalWidth,
height = 1
) {
return new Promise((resolve, reject) => {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = originalWidth;
canvas.height = height;
// 绘制图片到Canvas
ctx.drawImage(
img,
0,
sourceStartY - height, // 源图片起始位置
originalWidth,
height, // 源图片尺寸
0,
0, // 目标起始位置
originalWidth,
height // 目标尺寸
);
// 获取像素数据(Uint8ClampedArray,长度为width*height*4)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let isBalnk = true; // 标记是否纯黑
// 遍历所有像素(每4个值为一个像素:R, G, B, A)
for (let i = 0; i < data.length; i += 4) {
const r = data[i]; // 红
const g = data[i + 1]; // 绿
const b = data[i + 2]; // 蓝
const a = data[i + 3]; // 透明度(0=全透,255=不透明)
if (a === 0) continue; // 跳过透明像素
// 检测是否纯黑(R=0, G=0, B=0)
if (r !== rgb[0] || g !== rgb[1] || b !== rgb[2]) {
isBalnk = false;
break;
}
}
console.log(isBalnk, "---isBlank");
// 清理canvas
canvas.remove();
resolve(isBalnk);
} catch (err) {
reject(new Error(`像素检测失败:${err.message}`));
}
});
}
export async function getNoBlankHeight(
num = 10,
rgb = [],
img,
sourceStartY,
originalWidth,
height = 1
) {
let i = 0;
// 第一步:检测初始位置是否为空白
const isBlank = await isImageBlank(
rgb,
img,
sourceStartY,
originalWidth,
height
);
// 如果初始位置就是空白,返回明确的语义值(比如0,或根据业务改-1)
if (isBlank) {
return 0; // 或 return -1 表示"起始位置即空白"
}
// 第二步:向上循环检测,最多num次
while (i < num) {
i++; // 第i次检测(1~num)
console.log(i, "---向上找");
const currentY = sourceStartY - i * height;
const _isBlank = await isImageBlank(
rgb,
img,
currentY,
originalWidth,
height
);
// 找到空白,返回向上偏移的像素高度(i * height)
if (_isBlank) {
return i * height;
}
}
// 循环结束未找到空白,返回明确的语义值(比如num * height,或-1)
// 注意:不要返回i(次数),要和上面的返回值类型统一(像素高度)
return num * height; // 或 return -1 表示"未找到空白"
}
export function listSum(list, index) {
let sum = 0;
if (index < 0) return sum;
if (index === 0) return list[0];
if (index > list.length - 1) return sum;
for (let i = 0; i <= index; i++) {
sum += list[i];
}
return sum;
}
思路
以上代码解决移动端不能下载的问题,其次不完美解决文字被切割的问题,核心思路是通过canvas扫描每一页末位的像素值,是不是为空,不为空则说明此处被切割了,然后需要循环往上扫描,直到扫到空。我这边扫描次数40次,高度1px,可以根据自己项目去调整看。