如何使用纯前端方式将页面信息进行优雅的下载

284 阅读3分钟

前端下载

前端经常会遇到一些下载需求,常规的像打印、图片、word、PDF等等,以下是我对纯前端下载的一些整理

打印

打印是最简单的需求,也是最容易实现的

window.print()

可以直接调用 window.print() 方法,所有浏览器都支持 print()

但是这个方法会打印所有页面内容,无法指定打印那些内容,此时需要做一些额外工作

添加 css
function windowPrint(style) {
  const styleElement = document.createElement("style");
  const head = document.querySelector("head");
  styleElement.onload = function () {
      window.print();
      setTimeout(() => {
          head.removeChild(styleElement);
      }, 1000);
  };
  styleElement.innerHTML = `@media print {
     ${style}
  }`;
  head.appendChild(styleElement);
}

如果需要分页处理可以增加样式

@media print .page-break-auto {
    page-break-before: auto!important;
    page-break-after: auto!important;
    page-break-inside: avoid!important;
}
iframe.contentWindow.print()

将打印内容放入iframe,打印完成后移除

function iframePrint() {
    const iframe = document.createElement("iframe");

    let str = ''
    const styles = document.querySelectorAll('style,link')
    for (let i = 0; i < styles.length; i++) {
        str += styles[i].outerHTML
    }

    const f = document.body.appendChild(iframe)
    // 将iframe不可见
    iframe.style = 'position:absolute;width:0;height:0;top:-10px;left:-10px;'
    const frameWindow = f.contentWindow || f.contentDocument
    const doc = f.contentDocument || f.contentWindow.document
    doc.open()
    doc.write(str + dom.outerHTML)
    doc.close()
    iframe.onload = function() {
        frameWindow.focus()
        frameWindow.print()
    }
}

下载图片

下载图片需要借助第三方插件 html2canvas

function createCanvns(element: HTMLElement) {
    const canvas = await html2canvas(element, {
      useCORS: true,
      imageTimeout: 0,
      scale: 2,
      onclone: (doc) => {},
      ignoreElements: (element) => {
      },
    });
    return canvas;
}

下载 PDF

下载 PDF 需要借助第三方插件 jspdf

import { jsPDF } from "jspdf";
import html2canvas from "html2canvas";

interface Blank {
  height: number;
  width: number;
  x: number;
  y: number;
}

  


class PdfBlank implements Blank {
  height: number;
  width: number;
  x: number;
  y: number;
  constructor(blank: Blank) {
    this.height = blank.height;
    this.width = blank.width;
    this.x = blank.x;
    this.y = blank.y;
  }

}

interface PdfPage {
  height: number;
  position: number;
  blank?: Blank;

}

  


interface CanvasNode {
  node: HTMLElement;
  image: string;
  offsetHeight: number;
  offsetWidth: number;
  imageHeight: number;
  pages: PdfPage[];
  position: number;
}

interface PDFExportOptions {
  filename: string;
  onProgress?: (progress: number) => void;
}

export class PDFExporter {
  scale = 2;
  width = 595.28;
  height = 841.89;
  async exportToPDF(element: HTMLElement, options: PDFExportOptions) {
    this.updateProgress(0, options.onProgress);
    console.time("exportToPDF");
    const nodes = await this.getPageNode(element, options);
    await new Promise((resolve) => setTimeout(resolve, 10));
    await this.getNodeCanvas(nodes, options);
    await this.createPdf(nodes, options);
    console.timeEnd("exportToPDF");
    this.updateProgress(100, options.onProgress);
  }

  async createCanvns(element: HTMLElement) {
    const canvas = await html2canvas(element, {
      useCORS: true,
      imageTimeout: 0,
      scale: this.scale,
      onclone: (doc) => {},
      ignoreElements: (element) => {
        return (
          element.classList.contains("canvas-ignore-this")
        );
      },
    });

    return canvas;

  }

  async getPageNode(element: HTMLElement, options: PDFExportOptions) {
    const stack: { node: Node }[] = [{ node: element }];
    const results: CanvasNode[] = [];
    let position = 0;
    // 更新进度
    let pageProgress = 0;
    while (stack.length > 0) {
      if (pageProgress++ < 20) {
        this.updateProgress(pageProgress++, options.onProgress);
      }

      const { node } = stack.pop()!;

      if (node instanceof HTMLElement) {
        const { createCanvasGroup, createCanvas } = node.dataset;
        if (createCanvas != null) {
          // 获取最小截图元素
          const rect = node.getBoundingClientRect();
          const offsetHeight = rect.height; // 包含小数部分的精确高度
          const offsetWidth = rect.width; // 包含小数部分的精确高度
          const imageHeight = (this.width / offsetWidth) * offsetHeight;
          const canvasNode: CanvasNode = {
            node,
            image: "",
            offsetHeight,
            offsetWidth,
            imageHeight,
            position,
            pages: [],
          };

          if (position + imageHeight < this.height) {
            canvasNode.pages.push({
              height: imageHeight,
              position,
            });

            position += imageHeight;

          } else {

            // 获取分页
            this.getNodePage(canvasNode);
            position = canvasNode.position;
          }

          results.push(canvasNode);

        } else if (createCanvasGroup != null) {
          const children = Array.from(node.childNodes);
          const childrenLength = children.length;
          for (let i = childrenLength - 1; i >= 0; i--) {
            stack.push({ node: children[i] });
          }
        }
      }
    }

    return results;

  }

  async getNodeCanvas(canvasNodes: CanvasNode[], options: PDFExportOptions) {
    // 更新进度 - 60
    const canvasNodesLength = canvasNodes.length;
    for (let i = 0; i < canvasNodesLength; i++) {
      const pageProgress = 20 + (40 * i) / canvasNodesLength;
      this.updateProgress(pageProgress, options.onProgress);
      const canvasNode = canvasNodes[i];
      const canvas = await this.createCanvns(canvasNode.node);
      const image = canvas.toDataURL("image/jpeg", 1);
      canvasNode.image = image;
    }
  }

  getNodePage(canvasNode: CanvasNode) {

    const { node, image } = canvasNode;
    const stack: { node: Node }[] = [];
    const results: PdfPage[] = [];
    // 获取上一张图的position
    let firstPosition = canvasNode.position;
    const children = Array.from(node.childNodes);
    const childrenLength = children.length;
    for (let i = childrenLength - 1; i >= 0; i--) {
      stack.push({ node: children[i] });
    }

    let imageHeight = 0;
    let position = firstPosition;
    while (stack.length > 0) {
      const { node } = stack.pop()!;
      if (node instanceof HTMLElement) {
        const rect = node.getBoundingClientRect();
        const offsetHeight = rect.height; // 包含小数部分的精确高度
        const offsetWidth = rect.width; // 包含小数部分的精确高度
        const height = (this.width / offsetWidth) * offsetHeight;
        // 如果高度大于pdf高度,则需要递归子元素
        if (height > this.height) {
          const children = Array.from(node.childNodes);
          if (children.length > 0) {
            const childrenLength = children.length;
            for (let i = childrenLength - 1; i >= 0; i--) {
              stack.push({ node: children[i] });
            }
            continue;
          }
        }

        if (firstPosition + imageHeight + height > this.height) {
          const blankHeight = this.height - firstPosition - imageHeight;
          const blank = new PdfBlank({
            height: blankHeight,
            width: this.width,
            x: 0,
            y: firstPosition + imageHeight,
          });

          results.push({ height: imageHeight, position, blank });
          if (firstPosition) {
            position -= firstPosition;
          }
          position -= imageHeight;
          firstPosition = 0;
          imageHeight = 0;
        }
        imageHeight += height;
      }
    }
    if (imageHeight > 0) {
      results.push({ height: imageHeight, position });
    }
    // 最后一页的图片高度
    canvasNode.position = imageHeight;
    canvasNode.pages = results;
  }

  async createPdf(nodes: CanvasNode[], options: PDFExportOptions) {
    const pdf = new jsPDF("p", "pt", [this.width, this.height]);
    const nodeLength = nodes.length;
    const jProgress = 40 / nodeLength;
    for (let i = 0; i < nodeLength; i++) {
      const node = nodes[i];
      const { image, pages, imageHeight } = node;
      const pagesLength = pages.length;
      for (let j = 0; j < pagesLength; j++) {
        const pageProgress = 60 + (jProgress * j) / pagesLength;
        this.updateProgress(pageProgress, options.onProgress);
        const page = pages[j];
        const { height, position, blank } = page;
        if (height > 0) {
          pdf.addImage(image, "JPEG", 0, position, this.width, imageHeight);

          if (blank) {
            this.addBlank(pdf, blank);
            if (i !== pagesLength - 1 && j !== pagesLength - 1) {
              pdf.addPage();
            }
          }
        }
      }
    }
    pdf.save("test.pdf");
  }

  addBlank(pdf: jsPDF, blank: PdfBlank) {
    pdf.setFillColor(255, 255, 255);
    pdf.rect(
      blank.x,
      blank.y,
      Math.ceil(blank.width),
      Math.ceil(blank.height),
      "F"
    );
  }
  // 更新进度
  updateProgress(
    progress: number,
    onProgress?: (progress: number) => void
  ): void {
    if (onProgress && typeof onProgress === "function") {
      try {
        onProgress(Math.min(100, Math.max(0, Math.round(progress))));
      } catch (e) {
        console.warn("进度回调错误:", e);
      }
    }
  }
}

下载 word

下载 word 需要借助 docx 插件

import {

  Document,

  Paragraph,

  TextRun,

  Packer,

  Table,

  TableRow,

  TableCell,

  ImageRun,

  type FileChild,

  type ParagraphChild,

  type IRunOptions,

  type IParagraphOptions,

  type ITableCellOptions,

  type ITableOptions,

} from "docx";

  


import { saveAs } from "file-saver";

  


interface FileChildNode {

  getNode(): FileChild;

}

  


interface ParagraphChildNode {

  getNode(): ParagraphChild | null;

}

  


interface TableCellNode {

  getNode(): TableCell;

}

  


class TextRunNode implements ParagraphChildNode {

  options: IRunOptions;

  constructor(options: IRunOptions) {

    this.options = options;

  }

  getNode() {

    return new TextRun(this.options);

  }

}

  


class ImageRunNode implements ParagraphChildNode {

  type: string;

  data: Buffer | ArrayBuffer;

  width: number = 100;

  height: number = 100;

  constructor() {}

  


  getNode() {

    if (!this.data) {

      return null;

    }

    return new ImageRun({

      data: this.data,

      type: this.type as any,

      transformation: { width: this.width, height: this.height },

    });

  }

  


  getImageType(url: string) {

    const noQueryUrl = url.split("?")[0];

    const type = noQueryUrl.split(".").pop() || ("png" as any);

    return type;

  }

  async getImageBuffer(localImage: string, width: number, height: number) {

    const response = await fetch(localImage);

    this.data = await response.arrayBuffer();

    this.type = this.getImageType(localImage);

    this.width = width;

    this.height = height;

  }

  async getImageBufferFromUrl(url: string) {

    try {

      const response = await fetch(url);

      this.data = await response.arrayBuffer();

      this.type = this.getImageType(url);

      const domElements = document.querySelectorAll(`[src="${url}"]`);

  


      for (const domElement of Array.from(domElements)) {

        const { width, height } = domElement.getBoundingClientRect();

        if (width > 0 && height > 0) {

          this.width = width;

          this.height = height;

          break;

        }

      }

  


      const maxWidth = 595;

      // 限制最大宽度

      if (this.width > maxWidth) {

        this.height = this.height * (maxWidth / this.width);

        this.width = maxWidth;

      }

    } catch (error) {

      console.error(error);

    }

  }

}

  


class ParagraphNode implements FileChildNode {

  options: IParagraphOptions;

  children: ParagraphChildNode[];

  constructor(options: IParagraphOptions, children: ParagraphChildNode[] = []) {

    this.options = options;

    this.children = children;

  }

  push(child: ParagraphChildNode | ParagraphChildNode[]) {

    if (Array.isArray(child)) {

      this.children.push(...child);

    } else {

      this.children.push(child);

    }

  }

  getNode() {

    const children = this.children

      .map((child) => child.getNode())

      .filter(Boolean) as ParagraphChild[];

    return new Paragraph({

      ...this.options,

      children,

    });

  }

}

  


class TableNode implements FileChildNode {

  children: TableCellNode[];

  options: Partial<ITableOptions>;

  constructor(options: Partial<ITableOptions>, children: TableCellNode[] = []) {

    this.children = children;

    this.options = options;

  }

  getNode() {

    return new Table({

      ...this.options,

      rows: [

        new TableRow({

          children: this.children.map((child) => child.getNode()),

        }),

      ],

    });

  }

}

  


class TableCellNode implements TableCellNode {

  options: Partial<ITableCellOptions>;

  children: ParagraphNode[];

  constructor(

    options: Partial<ITableCellOptions>,

    children: ParagraphNode[] = []

  ) {

    this.options = options;

    this.children = children;

  }

  push(child: ParagraphNode | ParagraphNode[]) {

    if (Array.isArray(child)) {

      this.children.push(...child);

    } else {

      this.children.push(child);

    }

  }

  getNode() {

    return new TableCell({

      ...this.options,

      children: this.children.map((child) => child.getNode()),

    });

  }

}

  


class DocxDocument {

  children: FileChildNode[] = [];

  constructor() {}

  push(child: FileChildNode | FileChildNode[]) {

    if (Array.isArray(child)) {

      this.children.push(...child);

    } else {

      this.children.push(child);

    }

  }

  getChildren() {

    return this.children.map((child) => child.getNode());

  }

  


  async downloadDocx(filename: string) {

    const children = this.getChildren();

    const doc = new Document({

      sections: [

        {

          children,

        },

      ],

    });

    const blob = await Packer.toBlob(doc);

    saveAs(blob, filename);

  }

  


  /**

   * 像素(px) 转 docx 的 size(半磅单位)

   * @param {number} px - 像素值

   * @returns {number} docx 的 size 值

   */

  pxToSize(px: number | string) {

    px = Number(px);

    if (isNaN(px)) {

      px = 14;

    }

    return Math.round(px * 1.5); // px × 1.5 并四舍五入

  }

}

  


class HtmlNodes {

  html: string;

  color: string = "#262626";

  size: number = 14;

  constructor(html: string, size?: number, color?: string) {

    this.html = html;

    this.size = size || this.size;

    this.color = color || this.color;

  }

  


  // 判断块级元素

  isBlockElement(tagName: string) {

    return (

      tagName === "p" ||

      tagName === "div" ||

      tagName === "li" ||

      tagName === "h1" ||

      tagName === "h2" ||

      tagName === "h3" ||

      tagName === "h4" ||

      tagName === "h5" ||

      tagName === "h6" ||

      tagName === "br"

    );

  }

  


  rgbToHex(rgb: string): string {

    const result = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(rgb);

    if (!result) {

      return "000000";

    }

    const r = parseInt(result[1], 10);

    const g = parseInt(result[2], 10);

    const b = parseInt(result[3], 10);

    return ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);

  }

  


  /**
   * 像素(px) 转 docx 的 size(半磅单位)
   * @param {number} px - 像素值
   * @returns {number} docx 的 size 值
   */
  pxToSize(px: number | string) {
    px = Number(px);
    if (isNaN(px)) {
      px = 14;
    }
    return Math.round(px * 1.5); // px × 1.5 并四舍五入
  }


  checkPrecedingContent(node: HTMLElement) {
    const previousSibling = node.previousSibling;

    if (previousSibling && previousSibling.nodeType !== Node.ELEMENT_NODE) {
      return false;
    }
    const previousElementSibling = node.previousElementSibling;
    const isBlockElement =
      previousElementSibling &&
      this.isBlockElement(previousElementSibling?.tagName.toLowerCase());
    if (isBlockElement) {
      return true;
    }
    return false;
  }

  hasInlineUnderline(style: CSSStyleDeclaration) {
    return (
      style.textDecoration.includes("underline") ||
      style.textDecorationLine.includes("underline")
    );
  }

  hasInlineStrike(style: CSSStyleDeclaration) {
    return (
      style.textDecoration.includes("line-through") ||
      style.textDecorationLine.includes("line-through")
    );
  }

  isTextItalic(style: CSSStyleDeclaration) {
    // 检查 font-style
    if (style.fontStyle === "italic") return true;
    return false;
  }

  getTextRunOptions(node: HTMLElement): IRunOptions {
    const data: any = {};
    const style = node.style;
    // 换行
    let breakValue;
    const tagName = node.tagName.toLowerCase();

    switch (tagName) {
      case "p":
      case "div":
      case "li":
      case "h1":
      case "h2":
      case "h3":
      case "h4":
      case "h5":
      case "h6":
      case "br":
        breakValue = 1;
        break;
      case "b":
      case "strong":
        data.bold = true;
        break;
      case "i":
      case "em":
        data.italics = true;
        break;
      case "u":
        data.underline = { type: "single" };
        break;
      case "a":
        data.underline = { type: "single" };
        data.color = "#00AD63";
        break;
      case "sub":
        data.subscript = true;
        break;
      case "sup":
        data.superscript = true;
        break;
      default:
        if (this.checkPrecedingContent(node)) {
          breakValue = 1;
        }
        break;
    }
    data.break = breakValue;
    // 字体需要进行转换
    const sizeMap = {
      "xx-small": 8,
      "x-small": 9,
      small: 10,
      medium: 12,
      normal: 14,
      large: 16,
      "x-large": 18,
      "xx-large": 24,
      "xxx-large": 32,
      "xxxx-large": 40,
      "xxxxx-large": 48,
      "xxxxxx-large": 64,
      "xxxxxxx-large": 80,
    };
    const fontSize = style.fontSize || this.size || 14;
    const sizeValue = sizeMap[fontSize as keyof typeof sizeMap] || fontSize;
    const size = this.pxToSize(sizeValue);
    data.size = size;

    // 颜色 - rgb需要进行转换
    let color = style.color || data.color || this.color || "#262626";
    if (color.startsWith("rgb")) {
      color = this.rgbToHex(color);
    }

    if (!color.startsWith("#")) {
      color = `#${color}`;
    }
    data.color = color;
    // 背景颜色
    let backgroundColor = style.backgroundColor;
    if (backgroundColor) {
      if (backgroundColor.startsWith("rgb")) {
        backgroundColor = this.rgbToHex(backgroundColor);
      }
      // 去掉前面的#
      if (backgroundColor.startsWith("#")) {
        backgroundColor = backgroundColor.slice(1);
      }
      data.shading = {
        fill: backgroundColor,
      };
      console.log(style.backgroundColor, data.shading);
    }

    // 粗体
    data.bold = style.fontWeight === "bold" || Number(style.fontWeight) >= 500;

    // 下划线
    if (this.hasInlineUnderline(style)) {
      data.underline = { type: "single" };
    }

    // 斜体
    if (this.isTextItalic(style)) {
      data.italics = true;
    }

    // 删除线
    if (this.hasInlineStrike(style)) {
      data.strike = true;
    }
    // 检测DOM元素的上标/下标状态
    if (style.verticalAlign === "sub") {
      data.subscript = true;
    }
    if (style.verticalAlign === "super") {
      data.superscript = true;
    }
    return data;
  }

  async getNodes() {
    const parser = new DOMParser();
    const htmlDom = parser.parseFromString(this.html, "text/html");
    const rootNode = htmlDom.body;
    const stack: { node: Node; options: IRunOptions }[] = [
      { node: rootNode, options: {} },
    ];

    const results: (TextRunNode | ImageRunNode)[] = [];

    while (stack.length > 0) {
      const { node, options } = stack.pop()!;
      if (node.nodeType === Node.TEXT_NODE) {
        const text = node.textContent?.trim();
        if (text) {
          results.push(new TextRunNode({ text, ...options }));
        }
        continue;
      }

      if (node instanceof HTMLElement) {
        // 图片处理
        if (node.tagName === "IMG") {
          const src = node.getAttribute("src");
          if (src) {
            const imageRun = new ImageRunNode();
            await imageRun.getImageBufferFromUrl(src);
            results.push(imageRun);
          }
          continue;
        }
        const children = Array.from(node.childNodes);
        const options = this.getTextRunOptions(node);
        for (let i = children.length - 1; i >= 0; i--) {
          stack.push({ node: children[i], options });
        }
      }
    }
    return results;
  }
}

export {
  TextRunNode,
  ImageRunNode,
  HtmlNodes,
  ParagraphNode,
  TableNode,
  TableCellNode,
  DocxDocument,
  type ParagraphChildNode,
};