新增PrintDot桥接功能,支持本地打印机连接和配置,优化打印模板设计,允许多页表格重复显示,改进打印预览和设计器界面,确保用户体验流畅。

This commit is contained in:
geht
2026-04-17 19:00:30 +08:00
parent 2bd4c5584d
commit efb6a9f838
14 changed files with 1196 additions and 110 deletions

View File

@@ -0,0 +1,212 @@
/** 将可打印 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;
};
/**
* @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 [{ 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 = Math.max(widthMm, pxToMm(sw) + pad);
const sheetH = Math.max(1, heightMm);
const sliceH = Math.max(1, Math.round(mmToPx(sheetH) * scale));
const pdf = new jsPDF({ unit: 'mm', 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 = 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 pdf = new jsPDF({ unit: 'mm', 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();
}
}