diff --git a/web-admin/src/utils/crm/contract-pdf-pages.test.ts b/web-admin/src/utils/crm/contract-pdf-pages.test.ts new file mode 100644 index 000000000..9999fb65d --- /dev/null +++ b/web-admin/src/utils/crm/contract-pdf-pages.test.ts @@ -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) + }) +}) diff --git a/web-admin/src/utils/crm/contract-pdf-pages.ts b/web-admin/src/utils/crm/contract-pdf-pages.ts new file mode 100644 index 000000000..e9e791abc --- /dev/null +++ b/web-admin/src/utils/crm/contract-pdf-pages.ts @@ -0,0 +1,18 @@ +/** 按不含水印的截图计算页数,仅移除没有任何可见像素的尾页。 */ +export function countContractPdfPages(image: Pick): 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 +} diff --git a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue index 452e54cdf..91350fa97 100644 --- a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue +++ b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue @@ -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 diff --git a/web-admin/src/views/crm/quote/QuoteListPage.vue b/web-admin/src/views/crm/quote/QuoteListPage.vue index 5bda15eee..20c76914e 100644 --- a/web-admin/src/views/crm/quote/QuoteListPage.vue +++ b/web-admin/src/views/crm/quote/QuoteListPage.vue @@ -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