fix: 压缩合同与报价单 PDF 并按实际内容分页,避免文件过大和空白尾页。

This commit is contained in:
wuzilong committed 2026-10-30 11:40:08 +08:00
1 parent 120b743a39
commit 0453e90000
4 files changed
+98 -44

No files matched your search

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { countContractPdfPages } from './contract-pdf-pages'
function pixels(width: number, height: number, marks: number[]) {
const data = new Uint8ClampedArray(width * height * 4).fill(255)
for (const row of marks) data[(row * width + 1) * 4] = 0
return { width, height, data }
}
describe('countContractPdfPages', () => {
it('ignores trailing white padding that crosses onto a second page', () => {
assert.equal(countContractPdfPages(pixels(210, 330, [20, 295])), 1)
})
it('keeps even a single visible pixel on a second page', () => {
assert.equal(countContractPdfPages(pixels(210, 330, [20, 297])), 2)
})
it('does not add a page when content ends exactly at the page boundary', () => {
assert.equal(countContractPdfPages(pixels(210, 330, [296])), 1)
})
it('removes only trailing blank pages from a multipage contract', () => {
assert.equal(countContractPdfPages(pixels(210, 900, [20, 580])), 2)
})
it('keeps one page for an empty document', () => {
assert.equal(countContractPdfPages(pixels(210, 330, [])), 1)
})
it('keeps faint content while ignoring fully transparent pixels', () => {
const image = pixels(210, 330, [20, 310])
const offset = (310 * image.width + 1) * 4
image.data[offset] = 254
assert.equal(countContractPdfPages(image), 2)
image.data[offset + 3] = 0
assert.equal(countContractPdfPages(image), 1)
})
})
@@ -0,0 +1,18 @@
/** 按不含水印的截图计算页数,仅移除没有任何可见像素的尾页。 */
export function countContractPdfPages(image: Pick<ImageData, 'width' | 'height' | 'data'>): number {
const { width, height, data } = image
const pageHeight = (width * 297) / 210
for (let y = height - 1; y >= 0; y -= 1) {
for (let x = 0; x < width; x += 1) {
const offset = (y * width + x) * 4
// 不使用深色阈值,保留浅色文字、细表格线和印章。
if (
data[offset + 3] > 0 &&
(data[offset] < 255 || data[offset + 1] < 255 || data[offset + 2] < 255)
) {
return Math.max(1, Math.ceil((y + 1) / pageHeight))
}
}
}
return 1
}
@@ -1059,6 +1059,7 @@ import jsPDF from 'jspdf';
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
import { applySavedProductTableLayout, stampSavedProductTableLayout } from '@/utils/crm/contract-table-layout';
import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
import { countContractPdfPages } from '@/utils/crm/contract-pdf-pages';
import {
downloadCrmAttachment,
getCrmAttachmentCategoryLabel,
@@ -3205,14 +3206,25 @@ const handleGeneratePdfFromDialog = async () => {
}
// 2. 使用html2canvas转换为canvas(PNG 无损,避免 JPEG 压糊文字)
const canvas = await html2canvas(tempDiv, {
const canvasOptions = {
scale: getContractPdfCanvasScale(),
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
width: 794,
windowWidth: 794
};
// 先排除装饰水印判断实际内容页数,模板底部 padding 不应生成空白尾页。
let canvas = await html2canvas(tempDiv, {
...canvasOptions,
ignoreElements: (element) => element.hasAttribute('data-contract-watermark')
});
const context = canvas.getContext('2d');
if (!context) throw new Error('无法读取合同截图');
const pageCount = countContractPdfPages(context.getImageData(0, 0, canvas.width, canvas.height));
if (isSXSupplier) {
canvas = await html2canvas(tempDiv, canvasOptions);
}
// 3. 移除临时容器
document.body.removeChild(tempDiv);
@@ -3226,30 +3238,16 @@ const handleGeneratePdfFromDialog = async () => {
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
format: 'a4',
compress: true // 无损压缩整页 PNG,避免原始像素直接写入导致 PDF 过大
});
const imgData = canvas.toDataURL('image/png');
// 6. 如果内容高度小于等于一页,直接添加
if (imgHeight <= pageHeight) {
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
} else {
// 内容超过一页,需要分页
let heightLeft = imgHeight;
let position = 0;
// 添加第一页
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
// 添加后续页面
while (heightLeft > 0) {
position = -(imgHeight - heightLeft);
pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
// 6. 只输出有正文的页面,保留原截图比例和分页位置。
for (let page = 0; page < pageCount; page += 1) {
if (page > 0) pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, -page * pageHeight, imgWidth, imgHeight);
}
// 7. 下载PDF
+21 -23
View File
@@ -898,6 +898,7 @@ import yzImg from '@/assets/images/yz.png';
import ydImg from '@/assets/images/yd.png';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { countContractPdfPages as countQuotePdfPages } from '@/utils/crm/contract-pdf-pages';
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
import QuoteFormDialog from './components/QuoteFormDialog.vue'
@@ -3062,6 +3063,7 @@ const handleGeneratePdf = async () => {
// 如果是SX编码,添加水印背景层
if (showBackground) {
const watermarkBg = document.createElement('div');
watermarkBg.setAttribute('data-quote-watermark', '1');
watermarkBg.style.cssText = `
position: absolute;
top: 0;
@@ -3103,15 +3105,25 @@ const handleGeneratePdf = async () => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
// 使用html2canvas转换为canvas
const canvas = await html2canvas(tempDiv, {
// 先排除水印判断实际内容页数,避免底部留白生成空白尾页。
const canvasOptions = {
scale: 2, // 提高清晰度
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
width: 794,
windowWidth: 794
};
let canvas = await html2canvas(tempDiv, {
...canvasOptions,
ignoreElements: (element) => element.hasAttribute('data-quote-watermark')
});
const context = canvas.getContext('2d');
if (!context) throw new Error('无法读取报价单截图');
const pageCount = countQuotePdfPages(context.getImageData(0, 0, canvas.width, canvas.height));
if (showBackground) {
canvas = await html2canvas(tempDiv, canvasOptions);
}
// 移除临时容器
document.body.removeChild(tempDiv);
@@ -3125,30 +3137,16 @@ const handleGeneratePdf = async () => {
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
format: 'a4',
compress: true // 无损压缩整页 PNG,保留文字与表格清晰度
});
const imgData = canvas.toDataURL('image/jpeg', 1.0);
const imgData = canvas.toDataURL('image/png');
// 如果内容高度小于等于一页,直接添加
if (imgHeight <= pageHeight) {
pdf.addImage(imgData, 'JPEG', 0, 0, imgWidth, imgHeight);
} else {
// 内容超过一页,需要分页
let heightLeft = imgHeight;
let position = 0;
// 添加第一页
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
// 添加后续页面
while (heightLeft > 0) {
position = -(imgHeight - heightLeft);
pdf.addPage();
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
// 只输出有实际内容的页面,保留原截图比例和分页位置。
for (let page = 0; page < pageCount; page += 1) {
if (page > 0) pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, -page * pageHeight, imgWidth, imgHeight);
}
// 下载PDF