221 lines
7.8 KiB
Vue
221 lines
7.8 KiB
Vue
/** 将可打印 HTML 片段(非完整文档)转为 PDF Base64,供 PrintDot / 后端队列等使用 */
|
||
|
||
function mmToPx(mm: number) {
|
||
return (mm * 96) / 25.4;
|
||
}
|
||
|
||
function pxToMm(px: number) {
|
||
return (px * 25.4) / 96;
|
||
}
|
||
|
||
/**
|
||
* 原生模板大量 position:absolute,子块可能低于父级 scrollHeight;
|
||
* 用后代包围盒与 scroll 尺寸取大,避免栅格化高度不足、末页空白。
|
||
*/
|
||
function measureCaptureExtentPx(root: HTMLElement): { sw: number; sh: number } {
|
||
const base = root.getBoundingClientRect();
|
||
let maxR = 0;
|
||
let maxB = 0;
|
||
const visit = (el: Element) => {
|
||
const r = (el as HTMLElement).getBoundingClientRect();
|
||
maxR = Math.max(maxR, r.right - base.left);
|
||
maxB = Math.max(maxB, r.bottom - base.top);
|
||
Array.from(el.children).forEach(visit);
|
||
};
|
||
visit(root);
|
||
const sw = Math.max(1, Math.ceil(maxR), root.scrollWidth, root.clientWidth);
|
||
const sh = Math.max(1, Math.ceil(maxB), root.scrollHeight, root.clientHeight);
|
||
return { sw, sh };
|
||
}
|
||
|
||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||
const bytes = new Uint8Array(buffer);
|
||
const chunkSize = 0x8000;
|
||
let binary = '';
|
||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||
const chunk = bytes.subarray(i, i + chunkSize);
|
||
binary += String.fromCharCode(...chunk);
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
/** 从 renderNativePrintHtml 等生成的完整 HTML 文档中取出 body 内层,便于套入与 hiprint 相同的 PDF 容器逻辑 */
|
||
export function extractBodyInnerHtmlFromFullDocument(fullHtml: string): string {
|
||
try {
|
||
const doc = new DOMParser().parseFromString(fullHtml, 'text/html');
|
||
const inner = doc.body?.innerHTML?.trim();
|
||
return inner || fullHtml;
|
||
} catch {
|
||
return fullHtml;
|
||
}
|
||
}
|
||
|
||
export type BuildPdfFromHtmlOptions = {
|
||
/**
|
||
* true(默认):widthMm/heightMm 为单张纸宽高,按高度纵向切片为多页 PDF,对齐浏览器打印分页。
|
||
* false:整张版面压成一页 PDF(长图一页,一般仅特殊场景使用)。
|
||
*/
|
||
paginate?: boolean;
|
||
/**
|
||
* 是否严格使用入参纸张尺寸(默认 false 保持历史行为)。
|
||
* 原生模板桥接打印建议开启,避免内容测量误差把小标签纸扩成 A4。
|
||
*/
|
||
exactPaperSize?: boolean;
|
||
};
|
||
|
||
/**
|
||
* @param html 内部 HTML 片段(无外层 html/body)
|
||
* @param widthMm 纸张宽度 mm(分页模式下每页同宽)
|
||
* @param heightMm 纸张高度 mm;分页模式下为「每一页」高度;非分页时为版心高度下限
|
||
*/
|
||
export async function buildPdfBase64FromHtmlFragment(
|
||
html: string,
|
||
widthMm: number,
|
||
heightMm: number,
|
||
options: BuildPdfFromHtmlOptions = {},
|
||
): Promise<string> {
|
||
const paginate = options.paginate !== false;
|
||
const exactPaperSize = options.exactPaperSize === true;
|
||
const [{ jsPDF }, html2canvasModule] = await Promise.all([import('jspdf'), import('html2canvas')]);
|
||
const html2canvas = html2canvasModule.default;
|
||
const container = document.createElement('div');
|
||
container.style.position = 'fixed';
|
||
container.style.left = '-20000px';
|
||
container.style.top = '0';
|
||
container.style.width = 'auto';
|
||
container.style.maxWidth = 'none';
|
||
container.style.overflow = 'visible';
|
||
container.style.background = '#fff';
|
||
container.style.zIndex = '-1';
|
||
container.innerHTML = `<div class="lodop-print-root" style="width:${widthMm}mm;min-width:${widthMm}mm;margin:0;padding:0;background:#fff;overflow:visible;box-sizing:border-box;">${html}</div>`;
|
||
document.body.appendChild(container);
|
||
try {
|
||
const target = (container.querySelector('.lodop-print-root') || container) as HTMLElement;
|
||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||
|
||
const measure = () => {
|
||
const sw = Math.max(target.scrollWidth, target.clientWidth, 1);
|
||
const sh = Math.max(target.scrollHeight, target.clientHeight, 1);
|
||
return { sw, sh };
|
||
};
|
||
|
||
let { sw, sh } = measure();
|
||
const minWpx = mmToPx(Math.max(1, widthMm));
|
||
if (sw < minWpx) {
|
||
target.style.width = `${minWpx}px`;
|
||
}
|
||
if (sw > minWpx) {
|
||
target.style.width = `${sw}px`;
|
||
}
|
||
({ sw, sh } = measure());
|
||
let ext = measureCaptureExtentPx(target);
|
||
sw = Math.max(sw, ext.sw);
|
||
sh = Math.max(sh, ext.sh);
|
||
target.style.minHeight = `${sh}px`;
|
||
target.style.width = `${Math.max(minWpx, sw)}px`;
|
||
({ sw, sh } = measure());
|
||
ext = measureCaptureExtentPx(target);
|
||
sw = Math.max(sw, ext.sw);
|
||
sh = Math.max(sh, ext.sh);
|
||
|
||
const MAX_EDGE = 12000;
|
||
let scale = 2;
|
||
while (Math.max(sw, sh) * scale > MAX_EDGE && scale > 0.75) {
|
||
scale -= 0.25;
|
||
}
|
||
|
||
const canvas = await html2canvas(target, {
|
||
backgroundColor: '#ffffff',
|
||
scale,
|
||
useCORS: true,
|
||
allowTaint: true,
|
||
logging: false,
|
||
width: sw,
|
||
height: sh,
|
||
windowWidth: sw,
|
||
windowHeight: sh,
|
||
onclone: (_clonedDoc, clonedEl) => {
|
||
const root = (clonedEl.querySelector?.('.lodop-print-root') as HTMLElement) || (clonedEl as HTMLElement);
|
||
if (!root) {
|
||
return;
|
||
}
|
||
root.style.minHeight = `${sh}px`;
|
||
root.style.width = `${sw}px`;
|
||
root.style.overflow = 'visible';
|
||
const relaxOverflow = (el: HTMLElement) => {
|
||
const st = el.style;
|
||
if (st.overflow === 'hidden' || st.overflowX === 'hidden' || st.overflowY === 'hidden') {
|
||
st.setProperty('overflow', 'visible', 'important');
|
||
}
|
||
Array.from(el.children).forEach((c) => relaxOverflow(c as HTMLElement));
|
||
};
|
||
relaxOverflow(root);
|
||
},
|
||
});
|
||
|
||
const cw = Math.max(1, canvas.width);
|
||
const ch = Math.max(1, canvas.height);
|
||
const pad = 1;
|
||
|
||
if (paginate) {
|
||
const sheetW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, pxToMm(sw) + pad);
|
||
const sheetH = Math.max(1, heightMm);
|
||
const sliceH = Math.max(1, Math.round(mmToPx(sheetH) * scale));
|
||
const orientation = sheetW > sheetH ? 'landscape' : 'portrait';
|
||
const pdf = new jsPDF({ unit: 'mm', orientation, format: [sheetW, sheetH] });
|
||
let y = 0;
|
||
let first = true;
|
||
/** 余量不足一页高的 2% 时视为测量噪声,避免多出一页空白 */
|
||
const tailNoisePx = Math.max(8, Math.floor(sliceH * 0.02));
|
||
while (y < ch) {
|
||
const remain = ch - y;
|
||
if (!first && remain <= tailNoisePx) {
|
||
break;
|
||
}
|
||
const hPx = Math.min(sliceH, remain);
|
||
if (hPx < 1) {
|
||
break;
|
||
}
|
||
const pageCanvas = document.createElement('canvas');
|
||
pageCanvas.width = cw;
|
||
pageCanvas.height = hPx;
|
||
const ctx = pageCanvas.getContext('2d');
|
||
if (ctx) {
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, cw, hPx);
|
||
ctx.drawImage(canvas, 0, y, cw, hPx, 0, 0, cw, hPx);
|
||
}
|
||
const imgData = pageCanvas.toDataURL('image/jpeg', 0.92);
|
||
const imgHmm = sheetH * (hPx / sliceH);
|
||
if (!first) {
|
||
pdf.addPage();
|
||
}
|
||
first = false;
|
||
pdf.addImage(imgData, 'JPEG', 0, 0, sheetW, imgHmm);
|
||
y += sliceH;
|
||
}
|
||
return arrayBufferToBase64(pdf.output('arraybuffer'));
|
||
}
|
||
|
||
// 单页长图模式(paginate: false)
|
||
const contentWidthMm = pxToMm(sw);
|
||
const contentHeightMm = pxToMm(sh);
|
||
const minW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, contentWidthMm) + pad;
|
||
const minH = Math.max(heightMm, contentHeightMm) + pad;
|
||
const canvasRatio = cw / ch;
|
||
let pdfH = Math.max(minH, minW / canvasRatio);
|
||
let pdfW = pdfH * canvasRatio;
|
||
if (pdfW < minW) {
|
||
pdfW = minW;
|
||
pdfH = pdfW / canvasRatio;
|
||
}
|
||
const orientation = pdfW > pdfH ? 'landscape' : 'portrait';
|
||
const pdf = new jsPDF({ unit: 'mm', orientation, format: [pdfW, pdfH] });
|
||
const imgData = canvas.toDataURL('image/jpeg', 0.92);
|
||
pdf.addImage(imgData, 'JPEG', 0, 0, pdfW, pdfH);
|
||
return arrayBufferToBase64(pdf.output('arraybuffer'));
|
||
} finally {
|
||
container.remove();
|
||
}
|
||
}
|