福州网站制作工具深圳市罗湖区住房和建设局官网

张小明 2026/1/9 6:20:06
福州网站制作工具,深圳市罗湖区住房和建设局官网,泰安网站建设找工作,婚纱网站建设 最开始一、为什么需要自定义封装#xff1f;自个实现全局 hooks 可控#xff0c;想怎么来就怎么来#xff08;参考 html2pdf.js#xff09;。直接使用 html2canvas 和 jsPDF 通常会遇到#xff1a;内容被截断 / 超出容器内容生成不居中图片跨域污染导致失败Tailwind/UnoCSS 的样…一、为什么需要自定义封装自个实现全局 hooks 可控想怎么来就怎么来参考 html2pdf.js。直接使用 html2canvas 和 jsPDF 通常会遇到内容被截断 / 超出容器内容生成不居中图片跨域污染导致失败Tailwind/UnoCSS 的样式与 html2canvas 不兼容PDF 页面分页不正确文字渲染模糊文字下沉文本 baseline 问题要做到尽可能无损的渲染需要克隆 DOM在单独容器中渲染目前使用这个方法实现内容不居中问题处理 position/overflow/scroll 等不兼容属性修复不支持的 CSS 颜色函数oklab/oklch 等手动分页 图片等比缩放覆盖一些框架默认样式如 img display二、核心导出功能实现以下代码实现了完整的导出流程自动加载 html2canvas / jsPDFDOM 克隆渲染Canvas 控制切分页PDF 导出或预览进度回调代码较长这里展示关键核心结构export function useHtml2Pdf() {const exporting ref(false);const progress ref(0);const previewImages refstring[]([]);// 加载库const loadLibraries async () { ... }// DOM → Canvasconst renderToCanvas async () { ... }// Canvas → PDFconst splitCanvasToPdf () { ... }// 导出与预览const exportPdf async () { ... }const previewPdf async () { ... }return { exporting, progress, previewImages, exportPdf, previewPdf };}完整代码已在文章结尾处附带代码以及实现就不详细解读了。三、为什么会出现“文字下沉”这是使用 html2canvas 时 最常见也是最诡异的 bug 之一同一行文字字体不同图片与文字混合排版Tailwind / UnoCSS 的 baseline 设置img 默认为 display: inline 或 inline-blockhtml2canvas 会根据 CSS 计算内容 baseline高度计算错误具体表现文字被向下“压”了一点点与真实页面不一致某些容器中的文本垂直位置偏离根本原因UnoCSS / Tailwind 默认对 img 设置为display: inline;vertical-align: middle;导致 html2canvas 在计算行内盒高度时文字基线被迫下移图片对齐方式干扰文本html2canvas 本身对 inline-level box 的计算就比较脆弱因此这个默认样式会破坏它的内部排版逻辑。四、一行 CSS 解决文字下沉问题解决方案强制 img 不参与 inline 排版img {display: inline-block !important;}为什么这行能解决inline-block 会创建自己的盒模型不再参与行内 baseline 对齐。html2canvas 计算布局时不需要处理 inline-level baseline从而避免错位。避免 Tailwind/UnoCSS 默认的 vertical-align: middle 影响布局高度。这是经过大量社区使用、实测最稳定的解决方案。注意这种 bug 仅在截屏html2canvas时出现真实 DOM 渲染正常。五、完整代码以下不是最新版本有许多点可以进行优化更改可以参考各自需求import { ref, onMounted, type Ref } from vue;/*** html2canvas 配置选项*/export interface Html2CanvasOptions {scale?: number; // 清晰度倍数默认使用 devicePixelRatio * 1.5useCORS?: boolean; // 是否使用 CORSallowTaint?: boolean; // 是否允许跨域图片污染 canvasbackgroundColor?: string; // 背景色logging?: boolean; // 是否启用日志width?: number; // 宽度height?: number; // 高度windowWidth?: number; // 窗口宽度windowHeight?: number; // 窗口高度x?: number; // X 偏移y?: number; // Y 偏移scrollX?: number; // X 滚动scrollY?: number; // Y 滚动onclone?: (clonedDoc: Document, clonedElement: HTMLElement) void; // 克隆回调}/*** 图片质量配置*/export interface ImageOptions {type?: png | jpeg | webp; // 图片类型quality?: number; // 图片质量 0-1仅对 jpeg/webp 有效}/*** 页面分页配置*/export interface PagebreakOptions {mode?: (avoid-all | css | legacy)[]; // 分页模式before?: string | string[]; // 在此元素前分页after?: string | string[]; // 在此元素后分页avoid?: string | string[]; // 避免在此元素处分页enabled?: boolean; // 是否启用自动分页默认trueavoidSinglePage?: boolean; // 是否避免单页内容强制分页默认true}/*** PDF 页面格式类型*/export type PdfFormat | a0 | a1 | a2 | a3 | a4 | a5 | a6 | a7 | a8 | a9 | a10| b0 | b1 | b2 | b3 | b4 | b5 | b6 | b7 | b8 | b9 | b10| c0 | c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9 | c10| dl | letter | government-letter | legal | junior-legal| ledger | tabloid | credit-card| [number, number]; // 自定义尺寸 [width, height] in mm/*** PDF 导出配置选项*/export interface Html2PdfOptions {fileName?: string; // 文件名scale?: number; // 清晰度倍数默认使用 devicePixelRatio * 1.5padding?: number; // 页面内边距 (mm)format?: PdfFormat; // PDF格式默认为 a4orientation?: portrait | landscape; // 方向align?: left | center | right; // 内容对齐方式image?: ImageOptions; // 图片配置html2canvas?: PartialHtml2CanvasOptions; // html2canvas 配置pagebreak?: PagebreakOptions; // 分页配置onProgress?: (progress: number) void; // 进度回调 0-100}/*** 返回值类型*/export interface UseHtml2PdfReturn {exporting: Refboolean;progress: Refnumber; // 进度 0-100previewImages: Refstring[]; // 预览图片数组exportPdf: (element: HTMLElement | string | null,options?: Html2PdfOptions) Promisevoid;previewPdf: (element: HTMLElement | string | null,options?: Html2PdfOptions) Promisevoid;}/*** 使用 html2canvas jsPDF 生成 PDF* 适配 Vue 3 Nuxt.js 3*/export function useHtml2Pdf(): UseHtml2PdfReturn {const exporting ref(false);const progress ref(0);const previewImages refstring[]([]);const { $message } useNuxtApp();// 库实例let html2canvas: any null;let jsPDF: any null;// 库加载状态let librariesLoading: Promisevoid | null null;/*** 加载必要的库*/const loadLibraries async (): Promisevoid {if (librariesLoading) {return librariesLoading;}librariesLoading (async () {try {const [html2canvasModule, jsPDFModule] await Promise.all([import(html2canvas),import(jspdf),]);html2canvas html2canvasModule.default || html2canvasModule;jsPDF (jsPDFModule as any).jsPDF;} catch (error) {console.error(PDF 库加载失败:, error);throw error;}})();return librariesLoading;};// 在客户端预加载库if (process.client) {onMounted(() {loadLibraries().catch((error) {console.error(预加载 PDF 库失败:, error);});});}/*** 更新进度*/const updateProgress (value: number, callback?: (progress: number) void): void {progress.value Math.min(100, Math.max(0, value));if (callback) {callback(progress.value);}};/*** 获取目标元素*/const getElement (element: HTMLElement | string | null): HTMLElement | null {if (!element || process.server) return null;if (typeof element string) {return document.querySelector(element) as HTMLElement;}return element;};/*** 保存和恢复元素样式*/interface StyleState {overflow: string;maxHeight: string;height: string;scrollTop: number;scrollLeft: number;bodyOverflow: string;bodyScrollTop: number;}const saveStyles (element: HTMLElement): StyleState {return {overflow: element.style.overflow,maxHeight: element.style.maxHeight,height: element.style.height,scrollTop: element.scrollTop,scrollLeft: element.scrollLeft,bodyOverflow: document.body.style.overflow,bodyScrollTop: window.scrollY,};};const applyCaptureStyles (element: HTMLElement): void {element.style.overflow visible;element.style.maxHeight none;element.style.height auto;document.body.style.overflow hidden;element.scrollTop 0;element.scrollLeft 0;window.scrollTo(0, 0);};const restoreStyles (element: HTMLElement, state: StyleState): void {element.style.overflow state.overflow;element.style.maxHeight state.maxHeight;element.style.height state.height;element.scrollTop state.scrollTop;element.scrollLeft state.scrollLeft;document.body.style.overflow state.bodyOverflow;window.scrollTo(0, state.bodyScrollTop);};/*** 修复不支持的 CSS 颜色函数*/const fixUnsupportedColors (clonedDoc: Document, originalElement: HTMLElement): void {clonedDoc.body.style.backgroundColor #ffffff;clonedDoc.body.style.margin 0;clonedDoc.body.style.padding 0;const allElements clonedDoc.querySelectorAll(*);const colorProperties [color,background-color,background,border-color,border-top-color,border-right-color,border-bottom-color,border-left-color,outline-color,box-shadow,text-shadow,];allElements.forEach((el, index) {if (el instanceof HTMLElement) {// 尝试从原始文档找到对应元素let originalEl: HTMLElement | null null;if (originalElement) {const originalAll originalElement.querySelectorAll(*);if (originalAll[index]) {originalEl originalAll[index] as HTMLElement;}}const targetEl originalEl || el;try {const computedStyle window.getComputedStyle(targetEl, null);colorProperties.forEach((prop) {try {const computedValue computedStyle.getPropertyValue(prop);const styleValue targetEl.style.getPropertyValue(prop);if ((styleValue (styleValue.includes(oklab) ||styleValue.includes(oklch) ||styleValue.includes(lab() ||styleValue.includes(lch())) ||(computedValue (computedValue.includes(oklab) ||computedValue.includes(oklch)))) {if (computedValue (computedValue.includes(rgb) || computedValue.includes(#))) {el.style.setProperty(prop, computedValue, important);} else if (prop.includes(shadow)) {el.style.setProperty(prop, none, important);} else {el.style.removeProperty(prop);}}} catch (e) {// 忽略单个属性的错误}});} catch (e) {// 如果无法获取计算样式跳过该元素}}});if (originalElement) {(originalElement as HTMLElement).style.position relative;(originalElement as HTMLElement).style.width auto;(originalElement as HTMLElement).style.height auto;}};/*** 创建渲染容器*/const createRenderContainer (sourceElement: HTMLElement): { overlay: HTMLElement; container: HTMLElement; cleanup: () void } {// 创建 overlay 容器样式const overlayCSS {position: fixed,overflow: hidden,zIndex: 1000,left: 0,right: 0,bottom: 0,top: 0,backgroundColor: rgba(0,0,0,0.8),opacity: 0};// 创建内容容器样式const containerCSS {position: absolute,width: auto,left: 0,right: 0,top: 0,height: auto,margin: auto,backgroundColor: white};// 创建 overlay 容器const overlay document.createElement(div);overlay.className html2pdf__overlay;Object.assign(overlay.style, overlayCSS);// 创建内容容器const container document.createElement(div);container.className html2pdf__container;Object.assign(container.style, containerCSS);// 克隆源元素并添加到容器中const clonedElement sourceElement.cloneNode(true) as HTMLElement;container.appendChild(clonedElement);overlay.appendChild(container);document.body.appendChild(overlay);// 清理函数const cleanup () {if (document.body.contains(overlay)) {document.body.removeChild(overlay);}};return { overlay, container, cleanup };};/*** 渲染 DOM - Canvas*/const renderToCanvas async (element: HTMLElement,options?: {scale?: number;html2canvas?: PartialHtml2CanvasOptions;onProgress?: (progress: number) void;}): PromiseHTMLCanvasElement {// 确保库已加载await loadLibraries();if (!html2canvas) {throw new Error(html2canvas 未加载);}const {scale: customScale,html2canvas: html2canvasOptions {},onProgress: progressCallback,} options || {};const defaultScale (window.devicePixelRatio || 1) * 1.5;const finalScale customScale ?? html2canvasOptions.scale ?? defaultScale;const fullWidth element.scrollWidth || html2canvasOptions.width || element.offsetWidth;const fullHeight element.scrollHeight || html2canvasOptions.height || element.offsetHeight;// 保存样式const styleState saveStyles(element);applyCaptureStyles(element);// 创建渲染容器const { container, cleanup } createRenderContainer(element);const clonedElement container.firstElementChild as HTMLElement;// 等待布局稳定await new Promise((resolve) {requestAnimationFrame(() {requestAnimationFrame(resolve);});});updateProgress(10, progressCallback);try {// 合并默认配置和自定义配置const canvasOptions {scale: finalScale,useCORS: true,allowTaint: false,logging: false,backgroundColor: #ffffff,width: fullWidth,height: fullHeight,windowWidth: fullWidth,windowHeight: fullHeight,x: 0,y: 0,scrollX: 0,scrollY: 0,...html2canvasOptions,onclone: (clonedDoc: Document, clonedElementFromCanvas: HTMLElement) {fixUnsupportedColors(clonedDoc, element);// 执行用户自定义的 onclone 回调if (html2canvasOptions.onclone) {html2canvasOptions.onclone(clonedDoc, clonedElementFromCanvas);}},};updateProgress(20, progressCallback);// 使用克隆的元素进行渲染const canvas await html2canvas(clonedElement, canvasOptions);updateProgress(50, progressCallback);return canvas;} finally {// 清理容器cleanup();// 恢复样式restoreStyles(element, styleState);}};/*** 获取页面尺寸配置单位mm* 参考 jsPDF 的页面尺寸定义使用精确的 pt 到 mm 转换*/const getPageSizes (format: PdfFormat,orientation: portrait | landscape portrait): { width: number; height: number } {// 如果是自定义数组格式 [width, height]if (Array.isArray(format)) {return { width: format[0], height: format[1] };}// pt 到 mm 的转换因子1 pt 72/25.4 mmconst k 72 / 25.4;// 所有页面格式的尺寸单位pt// 参考 jsPDF 的页面格式定义const pageFormatsPt: Recordstring, [number, number] {// A 系列a0: [2383.94, 3370.39],a1: [1683.78, 2383.94],a2: [1190.55, 1683.78],a3: [841.89, 1190.55],a4: [595.28, 841.89],a5: [419.53, 595.28],a6: [297.64, 419.53],a7: [209.76, 297.64],a8: [147.40, 209.76],a9: [104.88, 147.40],a10: [73.70, 104.88],// B 系列b0: [2834.65, 4008.19],b1: [2004.09, 2834.65],b2: [1417.32, 2004.09],b3: [1000.63, 1417.32],b4: [708.66, 1000.63],b5: [498.90, 708.66],b6: [354.33, 498.90],b7: [249.45, 354.33],b8: [175.75, 249.45],b9: [124.72, 175.75],b10: [87.87, 124.72],// C 系列c0: [2599.37, 3676.54],c1: [1836.85, 2599.37],c2: [1298.27, 1836.85],c3: [918.43, 1298.27],c4: [649.13, 918.43],c5: [459.21, 649.13],c6: [323.15, 459.21],c7: [229.61, 323.15],c8: [161.57, 229.61],c9: [113.39, 161.57],c10: [79.37, 113.39],// 其他格式dl: [311.81, 623.62],letter: [612, 792],government-letter: [576, 756],legal: [612, 1008],junior-legal: [576, 360],ledger: [1224, 792],tabloid: [792, 1224],credit-card: [153, 243],};const formatLower format.toLowerCase();let pageSize: [number, number];if (pageFormatsPt.hasOwnProperty(formatLower)) {pageSize pageFormatsPt[formatLower];} else {// 默认使用 A4pageSize pageFormatsPt.a4;console.warn(未识别的页面格式 ${format}使用默认格式 A4);}// 转换为 mmlet width pageSize[0] / k;let height pageSize[1] / k;// 处理方向if (orientation portrait) {// 纵向确保宽度 高度if (width height) {[width, height] [height, width];}} else if (orientation landscape) {// 横向确保宽度 高度if (height width) {[width, height] [height, width];}}return { width, height };};/*** 将 Canvas 切分页、生成 PDF*/const splitCanvasToPdf (canvas: HTMLCanvasElement,options: {format: PdfFormat;orientation: portrait | landscape;padding: number;fileName: string;align?: left | center | right;image?: ImageOptions;pagebreak?: PagebreakOptions;onProgress?: (progress: number) void;},doDownload false): { pdf: any; images: string[] } {if (!jsPDF) {throw new Error(jsPDF 未加载);}const {format,orientation,padding,fileName,align center,image { type: jpeg, quality: 0.95 },pagebreak { enabled: false, avoidSinglePage: true },onProgress: progressCallback,} options;// 获取页面尺寸const pageSize getPageSizes(format, orientation);// 对于自定义尺寸数组格式需要特殊处理// jsPDF 构造函数格式new jsPDF(orientation, unit, format)// 如果 format 是数组 [width, height]则作为自定义尺寸传递const pdfFormat: string | [number, number] Array.isArray(format)? format: format;const pdf new jsPDF(orientation, mm, pdfFormat);const pageWidth pageSize.width;const pageHeight pageSize.height;// margin [top, left, bottom, right]// 这里 padding 相当于左右边距当四边相等时// 支持独立设置四个方向的边距默认只设置一个值const marginTop padding;const marginLeft padding;const marginBottom padding;const marginRight padding;// 可用内容区域考虑边距const innerWidth pageWidth - marginLeft - marginRight;const innerHeight pageHeight - marginTop - marginBottom;// 计算图片尺寸保持宽高比// 先计算基于可用区域的宽度和高度的比例看哪个更限制const widthRatio innerWidth / canvas.width;const heightRatio innerHeight / canvas.height;const scaleRatio Math.min(widthRatio, heightRatio);// 图片在 PDF 中的尺寸let imgWidth: number;let imgHeight: number;// 图片尺寸基于可用区域和内容比例imgWidth canvas.width * scaleRatio;imgHeight canvas.height * scaleRatio;// 确保图片不超过可用区域if (imgWidth innerWidth) {imgWidth innerWidth;imgHeight (canvas.height / canvas.width) * innerWidth;}if (imgHeight innerHeight) {imgHeight innerHeight;imgWidth (canvas.width / canvas.height) * innerHeight;}// 计算PDF页面在canvas像素坐标系中的高度// 1mm (canvas像素 / PDF尺寸mm) 的比例let pxPageHeight: number;if(pagebreak.enabled) {const pxPerMm canvas.width / (pageSize.width - marginLeft - marginRight);pxPageHeight Math.floor(innerHeight * pxPerMm);} else {pxPageHeight Math.floor(canvas.width * (imgHeight / imgWidth));}// 计算水平位置let xPosition: number;switch (align) {case left:// 左对齐从左边距开始xPosition marginLeft;break;case right:// 右对齐从右边距开始计算确保图片在右边xPosition pageWidth - marginRight - imgWidth;break;case center:default:// 居中计算居中位置xPosition marginLeft (innerWidth - imgWidth) / 2;break;}// 确定图片类型和质量const imageType image.type || jpeg;const imageQuality image.quality ?? (imageType png ? undefined : 0.95);const pdfImageFormat imageType png ? PNG : JPEG;// 确保图片质量在有效范围内const finalQuality imageQuality ! undefined? Math.max(0, Math.min(1, imageQuality)): undefined;const images: string[] [];// 根据配置决定是否分页const pxFullHeight canvas.height;let nPages 1;if (pagebreak.enabled) {// 计算需要的页数const calculatedPages Math.ceil(pxFullHeight / pxPageHeight);// 如果避免单页强制分页且内容不超过一页则不分页if (pagebreak.avoidSinglePage calculatedPages 1) {nPages 1;} else {nPages calculatedPages;}} else {nPages Math.ceil(pxFullHeight / pxPageHeight);;}// 估算总页数用于进度计算const estimatedTotalPages nPages;// 创建页面 canvasconst pageCanvas document.createElement(canvas);const pageCtx pageCanvas.getContext(2d);if (!pageCtx) {throw new Error(无法创建 Canvas 上下文);}pageCanvas.width canvas.width;pageCanvas.height pxPageHeight;// 分页处理for (let page 0; page nPages; page) {// 最后一页可能需要调整高度let currentPxPageHeight pxPageHeight;let currentPageHeight innerHeight;if (page nPages - 1 pxFullHeight % pxPageHeight ! 0) {// 最后一页使用剩余高度currentPxPageHeight pxFullHeight % pxPageHeight;currentPageHeight (currentPxPageHeight / canvas.width) * innerWidth;pageCanvas.height currentPxPageHeight;}// 清空并绘制当前页的内容pageCtx.fillStyle white;pageCtx.fillRect(0, 0, pageCanvas.width, currentPxPageHeight);pageCtx.drawImage(canvas,0,page * pxPageHeight,pageCanvas.width,currentPxPageHeight,0,0,pageCanvas.width,currentPxPageHeight);const sourceHeight (currentPageHeight / imgHeight) * canvas.height;// 根据配置生成图片数据const mimeType image/${imageType};const pageImgData finalQuality ! undefined? pageCanvas.toDataURL(mimeType, finalQuality): pageCanvas.toDataURL(mimeType);// 添加新页除了第一页if (page 0) {pdf.addPage();}// 添加图片到 PDFx marginLeft, y marginToppdf.addImage(pageImgData,pdfImageFormat,xPosition,marginTop,imgWidth,currentPageHeight);if (!doDownload) {images.push(pageImgData);}// 更新进度 (50-90%)if (progressCallback estimatedTotalPages 0) {const pageProgress 50 ((page 1) / estimatedTotalPages) * 40;updateProgress(pageProgress, progressCallback);}}updateProgress(95, progressCallback);if (doDownload) {pdf.save(fileName);updateProgress(100, progressCallback);}return { pdf, images };};/*** 导出 PDF* param element 需要导出的 DOM 元素或选择器* param options 配置项*/const exportPdf async (element: HTMLElement | string | null,options?: Html2PdfOptions): Promisevoid {// 服务端检查if (process.server) {if ($message) $
版权声明:本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!

开网站赚50万做网络项目发布平台

第一章:Open-AutoGLM调度引擎深度解析:如何实现毫秒级城市资源响应?Open-AutoGLM作为新一代智能调度引擎,专为高并发、低延迟的城市级资源调度场景设计。其核心架构融合了实时图神经网络推理与动态负载预测模型,能够在…

张小明 2026/1/9 3:25:00 网站建设

网站怎么做json数据wordpress简洁音乐播放器

还在为炉石传说的常规玩法感到乏味吗?今天我要分享一个让你眼前一亮的实用工具——HsMod配置!这款基于BepInEx框架的炉石传说增强工具,能够彻底改变你的游戏体验。 【免费下载链接】HsMod Hearthstone Modify Based on BepInEx 项目地址: h…

张小明 2026/1/6 0:47:05 网站建设

做视频的背景音乐哪里下载网站电子商务网站建设新闻

第一章:Open-AutoGLM 用药时间提醒在医疗健康类应用开发中,精准的用药时间提醒功能是提升用户体验与治疗依从性的关键。Open-AutoGLM 作为一个基于大语言模型的任务自动化框架,能够通过自然语言理解与任务调度机制,实现个性化的用…

张小明 2026/1/6 0:47:02 网站建设

大型网站开发技术html友情链接代码

第一章:人机协同操作的新模式探索在智能化系统快速演进的背景下,人机协同已从简单的指令响应发展为深度交互与联合决策的过程。现代应用中,人类操作者与AI代理共同完成复杂任务,例如自动驾驶中的驾驶员接管机制、医疗诊断系统中的…

张小明 2026/1/6 0:46:59 网站建设

佛山网站建设企业互助盘网站怎么做的

抖音视频批量下载终极指南:5步轻松保存精彩内容 【免费下载链接】douyin-downloader 项目地址: https://gitcode.com/GitHub_Trending/do/douyin-downloader 还在为错过精彩抖音视频而遗憾吗?douyin-downloader开源工具帮你轻松解决这个问题。作…

张小明 2026/1/6 0:46:57 网站建设

事业单位门户网站建设包含内容专门做任务的网站6

用STM32驱动无源蜂鸣器演奏音乐:从原理到实战的完整实现你有没有想过,一块几块钱的STM32最小系统板,加上一个小小的蜂鸣器,就能变成一台会“唱歌”的嵌入式设备?不是单调的“嘀嘀”报警声,而是真正能播放《…

张小明 2026/1/7 11:57:24 网站建设