It's me ;-)

Giải pháp cho việc hiển thị chữ xoay dọc ở trong cột của bảng khi sử dụng wkhtmltopdf

Dự án đang chạy của bọn tôi có tính năng là xuất các biểu mẫu sang dạng PDF. Do các biểu mẫu này có giao diện rất đơn giản, chủ yếu là table nên tôi nhanh chóng chọn 1 công nghệ cũ mèm nhưng lại chạy rất tốt với mấy yêu cầu đơn giản kiểu này, đó chính là wkhtmltopdf. Thư viện này có thể chuyển đổi các mã HTML sang PDF, hoặc chụp nội dung của 1 trang web (thông qua domain URL) sang PDF.

Mặc dù có khá nhiều tool có thể làm được việc này, xịn sò nhất có thể kể đến là thằng Puppeteer. Tuy nhiên tôi vẫn chọn wkhtmltopdf do các lý do sau:

  1. Đã từng làm việc với wkhtmltopdf ở nhiều dự án trước
  2. Kích thước thư viện nhỏ
  3. Hiệu năng sử dụng tốt (CPU & RAM tiêu tốn ít hơn rất nhiều so với Puppeteer)
  4. Tích hợp đơn giản chỉ với vài dòng code

Mọi thứ chạy nuột nà cho đến khi xuất hiện 1 biểu mẫu “oái oăm”: chữ ở 1 số cột cần phải được xoay dọc để hiển thị.

Trên trình duyệt thì việc dùng CSS để xoay chữ rất đơn giản. Anh bạn đồng nghiệp cũng đã chọn luôn hướng tiếp cận là xài thuộc tính `transform` của CSS để xoay đối tượng: `transform: rotate(90deg)`. Trên trình duyệt mọi thứ hiển thị khá hoàn hảo, nhưng khi chạy wkhtmltopdf để convert thì chữ không được xoay :))

Mình có nhận kèo cafe để hỗ trợ anh đồng nghiệp này. Nhưng trước khi bắt tay vào sửa lỗi thì mình nghĩ có thể do wkhtmltopdf không hỗ trợ hết các thuộc tính CSS (nó dùng Qt WebKit rendering engine), định add thêm các CSS prefix nhưng lại nghĩ: ủa, trong HTML table nó có sẵn thuộc tính nào hỗ trợ việc thay đổi hướng hiển thị của text không nhỉ?

Và may mắn & chuẩn chỉnh là thằng CSS nó có hỗ trợ:

  • writing-mode: vertical-rl: Thay đổi hướng văn bản theo chiều dọc, từ phải sang trái.
  • text-orientation: mixed: Xoay các ký tự của chuỗi ngang 90° theo chiều kim đồng hồ. Sắp xếp các ký tự của chữ viết dọc 1 cách tự nhiên
  • white-space: nowrap: Thêm thuộc tính này để đảm bảo chữ xoay ngang không bị xuống dòng

Okie, phần code HTML & CSS sẽ như sau:

<style>
 .th-90deg {
	writing-mode: vertical-rl;
	-webkit-writing-mode: vertical-rl;
	-ms-writing-mode: tb-rl;	
	text-orientation: mixed;
	white-space: nowrap;
}
 </style>
 <table class="items-table" id="items-table">
	<thead>
	  <tr>
		<th>No</th>
		<th>Column 1</th>
		<th>Column 2</th>
		<th class="th-90deg">
		  Rotated column 1
		</th>
		<th class="th-90deg">
		  Rotated column 2
		</th>
		<th class="th-90deg">
		  Rotated column 3
		</th>
		<th class="th-90deg">
		  Rotated column 4 with very long title
		</th>
	  </tr>
	</thead>
	<tbody>
	  <tr>
		<td class="text-center">1</td>
		<td>Lorem ipsum dolor sit amet</td>
		<td>Vitae commodi nesciunt recusandae</td>
		<td class="text-center">1</td>
		<td class="text-center">2</td>
		<td class="text-center">3</td>
		<td class="text-center">4</td>
	  </tr>
	  <tr>
		<td class="text-center">2</td>
		<td>Repellendus nobis optio in molestiae</td>
		<td>Iusto, numquam minus at libero aliquam sunt eum</td>
		<td class="text-center">1</td>
		<td class="text-center">2</td>
		<td class="text-center">3</td>
		<td class="text-center">4</td>
	  </tr>
	</tbody>
</table>

Trình duyệt hiển thị long lanh, nhưng khi dùng wkhtmltopdf convert thì text chả xoay tẹo nào =))

Browser render
PDF render – text không xoay dọc

Sửa lại chút HTML/CSS nào (bổ sung thêm thẻ span):

<style>
.th-90deg > span {
	writing-mode: vertical-rl;
	-webkit-writing-mode: vertical-rl;
	-ms-writing-mode: tb-rl;
}
 </style>
 <table class="items-table" id="items-table">
	<thead>
	  <tr>
		<th>No</th>
		<th>Column 1</th>
		<th>Column 2</th>
		<th class="th-90deg">
		  <span>Rotated column 1</span>
		</th>
		<th class="th-90deg">
		  <span>Rotated column 2</span>
		</th>
		<th class="th-90deg">
		  <span>Rotated column 3</span>
		</th>
		<th class="th-90deg">
		  <span>Rotated column 4 with very long title</span>
		</th>
	  </tr>
	</thead>
	...
</table>

Chạy lại wkhtmltopdf, text đã xoay được, nhưng… (lại nhưng) chiều rộng của cột lại bị co giãn theo đúng chiều rộng của text trước khi xoay 🥹

PDF render – text xoay dọc nhưng hiển thị sai chiều rộng cột

Quả này không ổn rồi, chỉ còn 1 cách nữa là cho thẻ span thành position: absolute; thì chiều rộng của cột sẽ không bị ảnh hưởng nữa tuy nhiên chúng ta sẽ phải chỉ định chiều cao xác định cho thẻ span này. Tức là sẽ phải đo chiều cao của từng đoạn text ở cột, sau đó lấy chiều cao lớn nhất gán vào style cho toàn bộ class .th-90deg & .th-90deg > span

Để tính được chiều cao lớn nhất này mình nghĩ có 2 cách:

  1. Dùng Javascript để tính chiều cao lớn nhất của các thẻ span
  2. Dùng server side để tính trước chiều cao này (chúng ta đã có số ký tự của text rồi, nên chắc có thể ước lượng được chiều cao tương ứng)

và mình chọn cách 1 cho nhanh (cách 2 thì chỉ nghĩ thôi chứ cũng không có nhu cầu thử 😁

const table = document.getElementById("items-table");
const rotatedHeaders = table.querySelectorAll(".th-90deg");
let maxHeight = 0;

// Find the maximum width of rotated text
rotatedHeaders.forEach((th) => {
  const span = th.querySelector("span");
  const spanHeight = span.offsetHeight;
  maxHeight = Math.max(maxHeight, spanHeight);
});

// Create a style element
const style = document.createElement("style");
style.textContent = `
  .th-90deg {
      height: ${height}px !important;
  }
  .th-90deg > span {
      height: ${height}px !important;
  }`;

// Append the style element to the document head
document.head.appendChild(style);

Nhìn code rất chuẩn chỉnh, mà íu hiểu sao chạy wkhtmltopdf nó lỗi tung toé luôn. Thế là lại phải xài thêm thuộc tính --debug-javascript để xem nó có lỗi gì không. Đáng ngạc nhiên là nó bão lỗi parse error, vậy là nghĩ ngay đến việc 1 trong mấy thành phần code ở trên không được thằng wkhtmltopdf hỗ trợ rồi. Vậy thì cho hết về Javascript thời Napoleon thôi:

var rotatedHeaders = document.getElementsByClassName("th-90deg");
var maxHeight = 0;

for (var i = 0; i < rotatedHeaders.length; i++) {
	var th = rotatedHeaders[i];
	var span = th.getElementsByTagName("span")[0];
	var spanHeight = span.offsetHeight;
	maxHeight = Math.max(maxHeight, spanHeight);
}

var style = document.createElement("style");
style.textContent =
	".th-90deg {height: " +
	maxHeight +
	"px !important;}.th-90deg > span {height: " +
	maxHeight +
	"px !important;}";
document.head.appendChild(style);
PDF Render – bản chuẩn

Okie, long lanh rồi. Cốc cafe của anh đồng nghiệp này hơi bị đắng 🤣