Bài toán header của trang khi xuất file pdf

Tình hình vừa rồi trong dự án mình có gặp một yêu cầu hơi kì kì từ phía khách hàng, giải pháp thì cũng đã tạm gọi là có nhưng thực sự vẫn chưa hoàn hảo cho lắm. Mình xin giới thiệu ở đây để mọi người cùng thảo luận và góp ý.

Bài toán

Bài toán cụ thể là như thế này. Ta có đối tượng product gồm các thông tin cơ bản và comments, và cần phải xuất file báo cáo dạng pdf với phần header của các trang là khác nhau. Cấu trúc file pdf yêu cầu như sau:

Các thông tin cơ bản của product sẽ hiển thị ở phần Content 1, còn danh sách comment sẽ hiển thị ở Content 2.

Nếu như phần Content 1 và Content 2 ngắn, chỉ nằm trên một trang thì không có vấn đề gì ở đây. Ta sẽ sử dụng thư viện dompdf để xuất file pdf từ template html. Tạo class PdfConverter như sau:

namespace App\Services;

use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Support\Facades\Storage;

class PdfConverter {
    protected $view;
    protected $data;
    protected $filename;

    public function __construct($view = '', $data = null, $filename = '') {
        $this->view = $view;
        $this->data = $data;
        $this->filename = $filename;
    }

    public function save() {
        $dompdf = new Dompdf;
        $html = view($this->view, $this->data)->render();
        $options = new Options;

        $options->setIsPhpEnabled(true);
        $options->setIsRemoteEnabled(true);
        $options->setIsFontSubsettingEnabled(true);
        $options->setIsHtml5ParserEnabled(true);

        $dompdf->setOptions($options);
        $dompdf->setPaper('A4', 'portrait');
        $dompdf->loadHtml($html);
        $dompdf->render();
        Storage::disk('local')->put($this->filename, $dompdf->output());
    }

    public function setView($view) {
        $this->view = $view;

        return $this;
    }

    public function setData($data) {
        $this->data = $data;

        return $this;
    }

    public function setFilename($filename) {
        $this->filename = $filename;

        return $this;
    }
}

Sau đó tạo file template report.blade.html:

<div class="main">
    <div class="header">
        Header 1
    </div>
    <div class="conent content_1">
        {{ $product->info }}
    </div>
    <div class="page-break"></div>
    <div class="header">
        Header 2
    </div>
    <div class="conent content_2">
		@foreach $product->comments as $comment
			{{ $comment->content }}
		@endforeach
    </div>
</div>

Cuối cùng là sử dụng class PdfConverter và template report.blade.html để xuất file pdf:

$this->pdfConverter
         ->setView('report.blade.html')
         ->setData($product)
         ->setFilename('report.pdf')
         ->save();

Nếu số lượng comment của chúng ta nhỏ phần Content 2 chỉ nằm trên một trang thì không có vấn đề gì xảy ra. Nhưng nếu ta có nhiều comment thì sao, khi đó phần Content 2 sẽ hiển thị sang trang 3, 4, 5... của report. Và khách hàng yêu cầu các trang 3, 4, 5... này cũng có header giống như của trang 2. Như vậy cách làm cũ không giải quyết được vấn đề nhỉ, vì các trang mới kia đâu có header. Tìm cách giải quyết khác thôi.

Hướng giải quyết

Ta sẽ sử dụng thư viện Setasign/FPDI để xử lý vấn đề này. Đây là thư viện cho phép chúng ta sử dụng một file pdf có sẽ làm template để từ đó có thể thao tác chỉnh sửa trên đó theo ý muốn. Với bài toán ở trên, thứ tự các bước làm là như sau:

  1. Tạo file pdf giống như ở trên, nhưng không có phần comment
  2. Sử dụng file pdf làm template
  3. Đọc trang 1 từ template vào
  4. Đọc trang 2 từ template vào
  5. Bổ sung thêm lần lượt từng comment vào trang 2. Trong quá trình bổ sung comment, chiều dài trang 2 vượt quá chiều dài tờ giấy A4 thì tạo một trang mới giống trang 2 của template (có phần header) rồi sau đó tiếp tục thêm comment vào trang mới này.

Lý thuyết là như thế còn sau đây là chi tiết 😄 Xóa bỏ div có class="content_2" ở report.blade.html. Tạo mới file comment.blade.html để hiện thị nội dung MỘT comment

<div class="comment">
	{{ $comment->content }}
</div>

Ở class PdfConverter thêm các function như sau:

    public function createReport() {
        $pdf = new FPDI();
        $pdf->setPrintHeader(false);
        $pdf->setPrintFooter(false);
        $pdf->SetAutoPageBreak(false);
		// sử dụng file pdf đã tạo làm template
        $pdf->setSourceFile(storage_path('app/' . $this->filename));

		//đọc page 1 từ template
        $importPageIndex = 1;
        $this->nextPage($pdf, $importPageIndex);
		//đọc page tiếp theo và bổ sung comment vào page này
        $importPageIndex++;
        $this->comments($pdf, $importPageIndex, $this->data['product']);
		//save file
        $pdf->Output($this->filename, 'I');
    }

    private function nextPage($pdf, $importPageIndex) {
		//đọc page $importPageIndex từ template
      	$importPage = $pdf->importPage($importPageIndex);
		//thêm một trang mới
      	$pdf->addPage();
		//ghi nội dung ở trang $importPageIndex vào page mới thêm
      	$pdf->useTemplate($importPage, 0, 0);
    }

    private function comments($pdf, $importPageIndex, $product) {
		//đọc page $importPageIndex từ template, ở đây là page 2
      	$importPage = $pdf->importPage($importPageIndex);
      	$pdf->addPage();
      	$pdf->useTemplate($importPage, 0, 0);
		// set vị trí con trỏ để bắt đầu thêm comment
      	$pdf->SetXY(10, 40);

      	foreach($product->comments as $comments) {
        	//clone bản pdf hiện tại sang $pdf2
            $pdf2 = clone $pdf;
            $html = view('comment')->with('comments', $comments)->render();
            //thêm một comment vào bản clone
            $pdf2->writeHTML($html, true, false, true, false, 'L');
            $y = $pdf->GetY();
            $y2 = $pdf2->GetY();
          	// nếu tọa độ con trở lớn hơn chiều cao khổ giấy A4 thì thêm page mới
            if ($y2 > 280) {
              	$pdf->addPage();
              	$pdf->useTemplate($importPage, 0, 0);
              	$pdf->Ln();
            }
            //thêm comment vào bản gốc
            $pdf->writeHTML($html, true, false, true, false, 'L');
      	}
    }

Cuối cùng là sửa lại lời gọi xuất file từ controller:

$this->pdfConverter
         ->setView('report.blade.html')
         ->setData($product)
         ->setFilename('report.pdf')
         ->save()
         ->createReport();

Vậy là bài toán có nhiều comment đã được giải quyết. Tuy nhiên ở đây còn một bài toán nữa có thể xảy ra. Đó là nếu Content 1 cũng quá dài và tràn sang trang 2, và trang 2 này yêu cầu cũng có Header 1. Khi đó cách làm trên sẽ không giải quyết được vấn đề. Mình cũng đã suy nghĩ vấn đề này nhưng rất tiếc là chưa tìm ra giải pháp. Hy vọng các bạn có cách giải quyết và chia sẻ ở đây 😃