pdf_service.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. """PDF 处理服务 —— MuPDF 文本提取、元数据解析与文件管理."""
  2. from __future__ import annotations
  3. import email.utils
  4. import hashlib
  5. import os
  6. import re
  7. from typing import Any
  8. from fastapi import HTTPException
  9. from starlette.requests import Request
  10. from starlette.responses import Response, StreamingResponse
  11. from ...settings import get_settings
  12. def _iter_file(path: str):
  13. with open(path, "rb") as f:
  14. while True:
  15. chunk = f.read(1024 * 256)
  16. if not chunk:
  17. break
  18. yield chunk
  19. def build_library_pdf_response(*, paper_id: int, request: Request, db_path: str, logger: Any) -> Response:
  20. from ...core.storage import PaperDatabase
  21. path = PaperDatabase(db_path).get_library_pdf_abspath(paper_id)
  22. if not path or not os.path.isfile(path):
  23. raise HTTPException(status_code=404, detail="本地 PDF 不存在")
  24. data_root = os.path.realpath(os.path.abspath(get_settings().data_dir))
  25. real_path = os.path.realpath(os.path.abspath(path))
  26. if real_path != data_root and not real_path.startswith(data_root + os.sep):
  27. logger.warning(
  28. "PDF 路径安全检查失败: paper_id=%d, path=%s, data_root=%s",
  29. int(paper_id),
  30. path,
  31. data_root,
  32. )
  33. raise HTTPException(status_code=403, detail="非法文件路径")
  34. st = os.stat(path)
  35. file_size = int(st.st_size)
  36. mtime = int(st.st_mtime)
  37. range_header = request.headers.get("range") or request.headers.get("Range")
  38. if_none_match = (request.headers.get("if-none-match") or request.headers.get("If-None-Match") or "").strip()
  39. if_modified_since = (
  40. request.headers.get("if-modified-since") or request.headers.get("If-Modified-Since") or ""
  41. ).strip()
  42. if_range = (request.headers.get("if-range") or request.headers.get("If-Range") or "").strip()
  43. etag_raw = f"{path}|{mtime}|{file_size}".encode("utf-8", "ignore")
  44. etag = 'W/"' + hashlib.sha1(etag_raw).hexdigest() + '"'
  45. last_modified = email.utils.formatdate(mtime, usegmt=True)
  46. common_headers: dict[str, str] = {
  47. "Content-Disposition": f"inline; filename=paper-{int(paper_id)}.pdf",
  48. "Accept-Ranges": "bytes",
  49. "Access-Control-Allow-Origin": "*",
  50. "Access-Control-Allow-Methods": "GET, OPTIONS",
  51. "Access-Control-Allow-Headers": "*",
  52. "Access-Control-Expose-Headers": "Accept-Ranges, Content-Range, Content-Length, ETag, Last-Modified",
  53. "Cache-Control": "public, max-age=3600",
  54. "ETag": etag,
  55. "Last-Modified": last_modified,
  56. }
  57. if not range_header:
  58. try:
  59. if if_none_match and if_none_match == etag:
  60. return Response(status_code=304, headers=common_headers)
  61. if if_modified_since:
  62. ims_ts = email.utils.parsedate_to_datetime(if_modified_since).timestamp()
  63. if int(ims_ts) >= mtime:
  64. return Response(status_code=304, headers=common_headers)
  65. except Exception:
  66. pass
  67. if not range_header:
  68. return StreamingResponse(
  69. _iter_file(path),
  70. media_type="application/pdf",
  71. headers={**common_headers, "Content-Length": str(file_size)},
  72. )
  73. if if_range:
  74. ok = if_range in (etag, last_modified)
  75. if not ok:
  76. return StreamingResponse(
  77. _iter_file(path),
  78. media_type="application/pdf",
  79. headers={**common_headers, "Content-Length": str(file_size)},
  80. )
  81. m = re.match(r"bytes=(\d+)-(\d*)", range_header.strip())
  82. if not m:
  83. return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
  84. start = int(m.group(1))
  85. end = int(m.group(2)) if m.group(2) else file_size - 1
  86. if start >= file_size:
  87. return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
  88. end = min(end, file_size - 1)
  89. if end < start:
  90. return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
  91. length = end - start + 1
  92. def iter_range():
  93. with open(path, "rb") as f:
  94. f.seek(start)
  95. remaining = length
  96. while remaining > 0:
  97. chunk = f.read(min(1024 * 256, remaining))
  98. if not chunk:
  99. break
  100. remaining -= len(chunk)
  101. yield chunk
  102. headers = {
  103. **common_headers,
  104. "Content-Range": f"bytes {start}-{end}/{file_size}",
  105. "Content-Length": str(length),
  106. }
  107. return StreamingResponse(iter_range(), status_code=206, media_type="application/pdf", headers=headers)