关键点说明

  1. 预览与下载的优先级

    • Content-Disposition: inline 要求浏览器优先内联显示(预览)。
    • 若浏览器不支持预览(如某些移动端),会自动触发下载。
  2. 性能与安全

    • 流式传输(非一次性加载到内存)支持大文件。
    • 校验文件存在性避免无效请求。
  3. 兼容性

    • 文件名编码处理确保中文等特殊字符正常显示。

SpringBoot预览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Controller
public class PdfController {

@Value("${pdf.file.path")
private String pdfFilePath;

@RequestMapping("/pdfpreview")
public void showPdf(HttpServletResponse response) throws IOException {
File file = new File(pdfFilePath);
// 校验文件是否存在
if (!file.exists()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "PDF文件不存在");
return;
}

response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "inline; filename=\"" + encodeFileName(file.getName()) + "\"");
response.setContentLength((int) file.length());

// 流式传输文件内容
try (FileInputStream input = new FileInputStream(file)) {
ServletOutputStream output = response.getOutputStream();
IOUtils.copy(input, output);
output.flush();
} catch (IOException e) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件处理失败");
// 记录日志 : log.error("PDF文件处理失败", e);
// e.printStackTrace();
}
}

// 处理文件名,防止中文乱码
private String encodeFileName(String fileName) throws UnsupportedEncodingException {
return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replaceAll("\\+", "%20");
}
}

HTTP Range 请求

HTTP 协议支持 Range 请求头,允许客户端请求文件的某一部分(字节范围),服务器可以响应 206 Partial Content 并返回指定范围的数据。这种方式非常适合大文件的懒加载。

实现步骤

  1. 客户端:在请求头中添加 Range 字段,例如:

    1
    Range: bytes=0-1023

    表示请求文件的前 1024 字节。

  2. 服务器

    • 解析 Range 请求头,获取请求的字节范围。
    • 使用 RandomAccessFileFileChannel 读取文件的指定部分。
    • 返回 206 Partial Content 状态码,并在响应头中设置 Content-Range
  3. 响应头

    1
    2
    3
    HTTP/1.1 206 Partial Content
    Content-Range: bytes 0-1023/123456
    Content-Length: 1024

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@CrossOrigin
@RequestMapping("/pdfpreview2")
public void showPdf(HttpServletRequest request, HttpServletResponse response) throws IOException {
File file = new File(pdfFilePath);

if (!file.exists()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "PDF文件不存在");
return;
}

long fileLength = file.length();
String rangeHeader = request.getHeader("Range");

// 设置通用响应头
response.setContentType("application/pdf");
response.setHeader("Accept-Ranges", "bytes");

if (rangeHeader == null) {
// 首次访问,只返回文件元数据
response.setHeader("Content-Disposition", "inline; filename=\"" + encodeFileName(file.getName()) + "\"");
response.setContentLength(0); // 不返回文件内容
} else {
// 解析 Range 请求头
String[] ranges = rangeHeader.replace("bytes=", "").split("-");
long start = Long.parseLong(ranges[0]);
long end = ranges.length > 1 ? Long.parseLong(ranges[1]) : fileLength - 1;

// 校验范围是否合法
if (start < 0 || end >= fileLength || start > end) {
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
response.setHeader("Content-Range", "bytes */" + fileLength);
return;
}

// 设置响应头
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
response.setContentLength((int) (end - start + 1));

// 使用 RandomAccessFile 读取指定范围
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
OutputStream output = response.getOutputStream()) {
randomAccessFile.seek(start);
byte[] buffer = new byte[1024];
long remaining = end - start + 1;
int bytesRead;

while (remaining > 0 && (bytesRead = randomAccessFile.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) {
output.write(buffer, 0, bytesRead);
remaining -= bytesRead;
}
}
}
}

前端实现懒加载

在前端,可以通过以下方式实现 PDF 的懒加载

  1. 使用 PDF.js(Mozilla 开源的 PDF 渲染库)

    • PDF.js 支持分块加载和渲染,适合大文件。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      <!DOCTYPE html>
      <html>
      <head>
      <title>滚动加载 PDF</title>
      <style>
      .page-container {
      margin: 20px auto;
      box-shadow: 0 0 5px #ccc;
      }
      </style>
      </head>
      <body>
      <div id="pdf-container"></div>

      <!-- 引入 PDF.js -->
      <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.min.js"></script>

      <script>
      const url = 'http://127.0.0.1:8080/pdfpreview2'; // 你的 PDF 接口地址
      let currentPage = 1; // 当前加载的页数
      let pdfInstance = null; // 保存 PDF 实例
      let isRendering = false; // 防止重复渲染

      // 初始化 PDF 加载
      const loadPDF = async () => {
      try {
      const loadingTask = pdfjsLib.getDocument({
      url: url,
      rangeChunkSize: 65536, // 分块大小(64KB)
      disableAutoFetch: true, // 禁用自动加载整个文件
      });

      pdfInstance = await loadingTask.promise;
      renderPage(currentPage); // 渲染第一页
      addScrollListener(); // 添加滚动监听
      } catch (error) {
      console.error("PDF 加载失败:", error);
      }
      };

      // 渲染单个页面
      const renderPage = async (pageNumber) => {
      if (isRendering || pageNumber > pdfInstance.numPages) return;
      isRendering = true;

      try {
      const page = await pdfInstance.getPage(pageNumber);
      const viewport = page.getViewport({ scale: 1.5 });

      // 创建画布容器
      const container = document.createElement('div');
      container.className = 'page-container';
      container.style.width = `${viewport.width}px`;
      container.style.height = `${viewport.height}px`;
      document.getElementById('pdf-container').appendChild(container);

      // 创建画布
      const canvas = document.createElement('canvas');
      const context = canvas.getContext('2d');
      canvas.height = viewport.height;
      canvas.width = viewport.width;
      container.appendChild(canvas);

      // 渲染页面
      await page.render({
      canvasContext: context,
      viewport: viewport,
      }).promise;

      console.log(`第 ${pageNumber} 页渲染完成`);
      currentPage++;
      isRendering = false;

      // 立即检查是否需要加载下一页
      checkScroll();
      } catch (error) {
      console.error(`第 ${pageNumber} 页渲染失败:`, error);
      isRendering = false;
      }
      };

      // 检查是否滚动到底部
      const checkScroll = () => {
      const lastPageContainer = document.querySelector('.page-container:last-child');
      if (!lastPageContainer) return;

      const lastPageBottom = lastPageContainer.offsetTop + lastPageContainer.offsetHeight;
      const scrollPosition = window.innerHeight + window.scrollY;

      // 如果距离底部小于 100px,加载下一页
      if (scrollPosition > lastPageBottom - 100) {
      renderPage(currentPage);
      }
      };

      // 添加滚动监听
      const addScrollListener = () => {
      window.addEventListener('scroll', checkScroll);
      };

      // 启动 PDF 加载
      loadPDF();
      </script>
      </body>
      </html>

总结

springboot-demo/pdf_lazy_loading at main · fulsun/springboot-demo