2025, Nov 05 18:01

Почему PDF открывается пустым в браузере и как исправить через Blob

Как исправить пустой PDF в браузере при Django/fpdf2 и фронтенде TypeScript/Vue: запрашивайте ответ как Blob, чтобы сохранить байты без искажений и открыть файл.

Если PDF в браузере открывается пустой страницей, а на сервере файл выглядит корректно, чаще всего проблема не в генераторе. Дело в том, как байты передаются и интерпретируются на стороне клиента. Ниже — короткий разбор реального кейса с fpdf2, Django и фронтендом на TypeScript/Vue: сервер отдавал валидный файл, но браузер показывал пустой документ. Решение оказалось полностью клиентским: запрашивать ответ как Blob, а не по умолчанию как текст/JSON.

Минимальная конфигурация, воспроизводящая пустой PDF

Бэкенд собирает очень простой PDF с помощью fpdf2 и возвращает его в HTTP-ответе. Если сохранить файл на диск из Python, он выглядит правильно. Проблема проявляется только при обращении к маршруту с фронтенда.

# файл: taskGeneratePDF.py
from fpdf import FPDF
from api.packages.utils.jsonLogger import LogFormat, get_or_create_json_logger

activity_log = get_or_create_json_logger(__name__, log_format=LogFormat.DEFAULT)


def build_task_pdf(job_payload: dict, out_path: str) -> bytearray:
    pdf_doc = FPDF()
    pdf_doc.add_page()
    pdf_doc.set_font("helvetica", style="B", size=16)
    pdf_doc.cell(40, 10, "Hello World!")

    activity_log.info(f"PDF generated and saved to {out_path}")
    file_bytes = pdf_doc.output()
    return file_bytes

Маршрут возвращает сгенерированные байты с корректными заголовками PDF.

# файл: TaskRoutesId.py
class TaskPdfRoute(GuardedRouteBase):
    def get(
            self,
            request: HttpRequest,
            *args,
    ) -> HttpResponse:
        try:
            pdf_bytes = build_task_pdf({"test": "test"}, "test.pdf")
            hdrs = {"Content-Disposition": "attachment; filename=myfilename.pdf"}
            return HttpResponse(bytes(pdf_bytes), content_type="application/pdf", headers=hdrs)
        except Exception as exc:
            return HttpResponse({"error": f"Failed to generate PDF: {str(exc)}"}, status=404)

Клиент вызывает эндпоинт и пытается скачать ответ как PDF.

// файл: TaskClient.ts
export async function fetchTaskPdf(taskId: string, payload: TaskPDFPayload) {
  try {
    const response = await httpGet<Uint8Array>(`/task/${taskId}/pdf/`)
    return response
  } catch (err) {
    return await Promise.reject(err)
  }
}
// файл: TaskMainDetails.vue
function trigger_pdf_download() {
  const payload = {}
  const jobId = props.task?.vertexID || ""
  const pdfPromise = TaskClient.fetchTaskPdf(jobId, payload)
  pdfPromise.then((msg) => {
    const blob = new Blob([msg.data], { type: "application/pdf" })
    const a = document.createElement("a")
    a.href = window.URL.createObjectURL(blob)
    a.download = "test_pdf_name"
    a.click()
  })
}

Что происходит на самом деле

Байты PDF доходят до клиента, но обрабатываются вспомогательной функцией запроса, не настроенной на бинарные данные. Когда ответ трактуется как текст или формат по умолчанию, последовательность байт искажается, и просмотрщик получает файл, который формально открывается, но рендерится пустым. Если просмотреть тело как текст, видно узнаваемую структуру PDF — это ещё один признак проблемы транспорта/декодирования, а не сбоя генератора.

Исправление в одну строку

Запрашивайте ответ на клиенте как Blob. Так бинарные данные сохраняются неизменными по всей цепочке.

Мне нужно было использовать httpGetBlob<Blob> в TaskClient.ts вместо httpGet

Вот обновлённый клиентский код.

// файл: TaskClient.ts
export async function fetchTaskPdfBlob(taskId: string, payload: TaskPDFPayload) {
  try {
    const response = await httpGetBlob<Blob>(`/task/${taskId}/pdf/`)
    return response
  } catch (err) {
    return await Promise.reject(err)
  }
}
// файл: TaskMainDetails.vue
async function trigger_pdf_download() {
  const payload = {}
  const jobId = props.task?.vertexID || ""
  const res = await TaskClient.fetchTaskPdfBlob(jobId, payload)

  const blob = new Blob([res.data], { type: "application/pdf" })
  const a = document.createElement("a")
  a.href = window.URL.createObjectURL(blob)
  a.download = "test_pdf_name"
  a.click()
}

Бэкенд можно оставить без изменений: возвращать байты с content_type = application/pdf и заголовком Content-Disposition для скачивания. Ключевая правка — на клиенте, где используется хелпер, умеющий работать с Blob.

Почему это важно

PDF, изображения, архивы и другие бинарные ресурсы должны доходить от сервера до браузера без потери ни одного байта. Если клиентская библиотека или хелпер трактует бинарный поток как текст или JSON, неочевидные искажения ломают файл, хотя ответ формально «успешный». Явный запрос Blob сохраняет исходные байты и предотвращает пустой рендер, битые загрузки и отказы просмотрщиков открывать документ.

Итоги

Если серверный генератор отдаёт валидный PDF, но в браузере вы видите пустую страницу, тогда как те же данные в виде текста выглядят нормально, сперва проверьте транспорт на клиенте. Используйте путь запроса, рассчитанный на Blob, для бинарных ответов. На сервере сохраняйте тип ответа application/pdf и возвращайте сырые байты. При такой настройке PDF будет скачиваться и отображаться как задумано.

Статья основана на вопросе на StackOverflow от Joe и ответе от Joe.