Bài viết kỹ thuật

Xây dựng một PDF tối thiểu bằng tay: 5 đối tượng bạn cần

Bản chất sâu xa của một tệp PDF là một thùng chứa văn bản thuần túy (plain-text container). Khi mở hầu hết các tệp trong một trình soạn thảo hex, phần trên cùng là có thể đọc được: một chú thích phiên bản, sau đó là một loạt các đối tượng được đánh số, rồi đến một chỉ mục nhỏ và một con trỏ ở phía dưới cùng báo cho trình đọc biết nơi để bắt đầu. Hãy loại bỏ tính năng nén và định dạng này đủ dễ tiếp cận để bạn có thể gõ một tài liệu hoạt động được vào trong một trình soạn thảo văn bản và dùng một trình xem để mở nó ra. Việc làm đó một lần sẽ dạy cho bạn nhiều điều về cách PDF gắn kết với nhau hơn bất kỳ lượng đọc thông số kỹ thuật nào, bởi vì bạn phải kết nối các đối tượng với nhau bằng tay và tệp sẽ từ chối mở cho đến khi bạn kết nối đúng

Hướng dẫn này xây dựng tệp PDF nhỏ nhất thực sự kết xuất ra một thứ gì đó: một trang, dòng chữ "Hello, World!" bằng một font chữ có sẵn, trên khổ giấy US Letter. Tệp hoàn chỉnh cần chính xác năm đối tượng và một vài dòng ghi chép xung quanh chúng. Chúng ta sẽ viết các đối tượng trước, sau đó lắp ráp phần tiêu đề (header), bảng tham chiếu chéo (cross-reference table), và phần đuôi (trailer) để liên kết chúng thành một tệp mà trình đọc sẽ chấp nhận

Năm đối tượng mà một trình xem luôn khăng khăng đòi hỏi

Một trình đọc không quét một tệp PDF từ trên xuống dưới để tìm kiếm nội dung. Nó bắt đầu ở phần trailer, lần theo một tham chiếu đến thư mục tài liệu (document catalog), và đi qua một chuỗi các đối tượng từ đó. Mọi đối tượng trên chuỗi đó phải tồn tại nếu không việc mở sẽ thất bại. Đối với một tài liệu một trang, chuỗi này rất ngắn, và mỗi liên kết có một công việc duy nhất:

  • Catalog là gốc. Đây là đối tượng mà trailer trỏ tới, và mục (entry) bắt buộc duy nhất của nó ở đây là một tham chiếu đến cây trang (page tree)
  • Pages là nút của cây trang. Nó liệt kê các trang trong tài liệu và báo cáo xem có bao nhiêu trang
  • Page mô tả một trang vật lý: kích thước của nó, các tài nguyên mà nó vẽ bằng, và luồng nội dung nào sơn nó
  • Content stream (luồng nội dung) chứa các toán tử vẽ, tức các lệnh hậu tố (postfix commands) đặt văn bản và đồ họa trên trang đó
  • Font khai báo kiểu chữ mà luồng nội dung tham chiếu đến. Hãy sử dụng một trong 14 font chữ tiêu chuẩn và bạn không phải nhúng bất cứ thứ gì

Mỗi đối tượng được đánh số và có thể định vị. Một đối tượng gián tiếp (indirect object) được viết là N 0 obj ... endobj, trong đó N là số thứ tự đối tượng và số 0 là số thế hệ của nó (luôn luôn là 0 trong một tệp bạn viết mới). Ở bất kỳ nơi nào khác trong tệp, bạn trỏ vào đối tượng đó bằng một tham chiếu: 5 0 R có nghĩa là "đối tượng 5." Những tham chiếu đó chính là phần dây nối. Catalog giữ 2 0 R trong cách đánh số của chúng ta để tiếp cận cây trang, cây trang giữ một tham chiếu quay ngược xuống page, và cứ như thế. Nếu làm sai một con số, trình đọc sẽ đi theo một con trỏ lơ lửng (dangling pointer) đi vào khoảng không vô định

Tên, từ điển, và luồng

Ba mảnh cú pháp mang theo gần như tất cả mọi thứ. Một tên (name) bắt đầu bằng một dấu gạch chéo: /Type, /Page, /F0. Các tên là các định danh phân biệt chữ hoa chữ thường (case-sensitive), không phải là chuỗi (strings), và PDF sử dụng chúng cho các khóa từ điển (dictionary keys) và để gắn thẻ cho biết một đối tượng là gì. Một từ điển (dictionary) là một bộ các cặp khóa-giá trị được bọc trong các dấu ngoặc nhọn kép, trong đó mọi khóa đều là một tên: << /Type /Page /MediaBox [0 0 612 792] >>. Các giá trị có thể là số, tên, mảng (nằm trong ngoặc vuông), các tham chiếu, hoặc các từ điển lồng nhau. Hầu hết các đối tượng PDF đều là các từ điển

Một luồng (stream) là một từ điển được theo sau bởi một khối byte nằm giữa các từ khóa streamendstream. Đó là nơi trú ngụ của các toán tử vẽ-trang, và trong các tệp thực tế đó cũng là nơi chứa các hình ảnh bị nén và các font chữ được nhúng. Từ điển của luồng mô tả các byte đó; trong một tệp sản xuất, nó phải mang một mục /Length cung cấp chính xác số lượng byte, và thường có một /Filter chẳng hạn như /FlateDecode khi dữ liệu bị nén. Chúng ta sẽ nhờ cậy vào một công cụ để điền vào /Length, bởi vì việc đếm byte bằng tay là một phần của bài tập này không mang lại bất kỳ lợi ích giáo dục nào mà lại có nguy cơ cao xảy ra lỗi sai lệch một đơn vị (off-by-one) làm hỏng tệp

Viết các đối tượng

Dưới đây là năm đối tượng theo thứ tự. Chi tiết về tọa độ cần lưu ý trước khi đọc luồng nội dung: PDF đo từ góc dưới cùng bên trái của trang bằng đơn vị point, trong đó một point bằng 1/72 inch, và Y tăng dần hướng lên trên. Một trang US Letter có kích thước 612 nhân 792 point, do đó 50 700 nằm ở gần phía trên cùng bên trái, chứ không phải ở phía dưới cùng

1 0 obj
<< /Type /Catalog
   /Pages 2 0 R
>>
endobj

2 0 obj
<< /Type /Pages
   /Kids [3 0 R]
   /Count 1
>>
endobj

3 0 obj
<< /Type /Page
   /Parent 2 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F0 4 0 R >> >>
   /Contents 5 0 R
>>
endobj

4 0 obj
<< /Type /Font
   /Subtype /Type1
   /BaseFont /Helvetica
>>
endobj

5 0 obj
<< /Length 44 >>
stream
BT
/F0 36 Tf
50 700 Td
(Hello, World!) Tj
ET
endstream
endobj

Hãy đọc các tham chiếu và cấu trúc sẽ lộ ra. Đối tượng 1, catalog, trỏ mục /Pages của nó vào đối tượng 2. Đối tượng 2, cây trang, liệt kê đối tượng 3 trong /Kids và khai báo /Count 1. Đối tượng 3, trang, trỏ /Parent ngược lên đối tượng 2 (cây và trang tham chiếu lẫn nhau, đây là yêu cầu bắt buộc), tự định cỡ bằng /MediaBox, phơi bày font chữ dưới cái tên cục bộ /F0 trong /Resources của nó, và nêu tên đối tượng 5 làm nội dung của nó. Đối tượng 4 là font chữ: /BaseFont /Helvetica chọn một trong 14 kiểu chữ tiêu chuẩn mà mọi trình đọc tuân thủ chuẩn đều đã có sẵn, vì vậy không có gì để nhúng. Đối tượng 5 là luồng nội dung

Luồng nội dung thực sự nói gì

Phần thân của luồng là một chương trình nhỏ xíu trong ngôn ngữ mô tả trang của PDF, đây là ngôn ngữ hậu tố (postfix): các toán hạng xuất hiện trước, sau đó là toán tử tiêu thụ chúng. Năm dòng thực hiện công việc. BTET mở và đóng một đối tượng văn bản; mọi thứ có chức năng định vị hoặc hiển thị văn bản đều phải nằm giữa chúng. /F0 36 Tf đặt font hiện tại thành tài nguyên có tên /F0 ở kích thước 36 point (Tf là "đặt font và kích thước văn bản"). 50 700 Td di chuyển vị trí văn bản tới (50, 700) trong tọa độ trang. (Hello, World!) Tj hiển thị chuỗi, thứ mà PDF viết dưới dạng văn bản nguyên văn nằm trong ngoặc đơn, sử dụng Tj để vẽ nó tại vị trí hiện tại. Nếu bỏ qua BT/ET thì một trình đọc nghiêm ngặt sẽ từ chối các toán tử văn bản; quên không thiết lập một font chữ trước Tj thì sẽ không có font chữ hiện tại nào để vẽ cùng

Tham số /Length 44 trong từ điển luồng là số đếm byte nằm giữa streamendstream, và nó phải chính xác. Đây là giá trị đáng để bàn giao cho một công cụ chứ không phải đếm số ký tự xuống dòng (newlines) bằng tay, đặc biệt là khi việc trình soạn thảo của bạn viết dấu kết thúc dòng (line endings) dưới dạng LF hay CRLF đều làm thay đổi tổng số

Header, xref, và trailer

Các đối tượng chính là nội dung. Ba cấu trúc thành phần biến chúng thành một tệp. Thành phần đầu tiên là header, dòng đầu tiên tiên, nêu tên định dạng và phiên bản:

%PDF-1.7

Dấu % bắt đầu một chú thích (comment) trong cú pháp PDF, nhưng một trình đọc lại coi chú thích cụ thể này như là chữ ký định dạng và đọc phiên bản từ nó. Một trình ghi (writer) thực sự sẽ theo sau nó ngay lập tức bằng một dòng chú thích thứ hai chứa các byte high-bit, đây là một gợi ý cho các công cụ truyền-tệp (file-transfer) rằng tệp này là tệp nhị phân và không được làm hỏng bằng cách coi nó như văn bản

Ở phần cuối của tệp là bảng tham chiếu chéo (cross-reference table), bảng chỉ mục giúp cho khả năng truy cập ngẫu nhiên trở nên khả thi. Nó ghi lại độ lệch byte (byte offset) của mọi đối tượng tính từ điểm bắt đầu của tệp, nhờ vậy trình đọc có thể tìm kiếm (seek) thẳng đến đối tượng 3 mà không cần phân tích đối tượng 1 và 2 trước. Bảng này mang tính cứng nhắc: các mục nhập có độ rộng cố định, mỗi mục 20 byte bao gồm cả dấu kết thúc dòng, được định dạng thành một khoảng offset gồm 10 chữ số, một số thế hệ 5 chữ số, một từ khóa (n cho đang-sử-dụng (in-use), f cho trống (free)), và một bộ kết thúc hai byte. Một bảng chính xác cho sáu mục nhập của chúng ta (đối tượng 0 luôn là đầu của danh sách-trống) trông giống như thế này:

xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000235 00000 n
0000000308 00000 n
trailer
<< /Size 6
   /Root 1 0 R
>>
startxref
408
%%EOF

Những khoảng offset đó là phần giòn tan dễ vỡ trong việc viết PDF bằng tay. Mỗi khoảng là một vị trí byte chính xác nơi mà một N 0 obj tương ứng bắt đầu, và mọi offset sẽ dịch chuyển ngay khi bạn thêm một ký tự vào bất kỳ nơi nào ở bên trên nó. Trailer là điểm vào (entry point) mà trình đọc sử dụng sau cùng và đầu tiên: /Root 1 0 R chỉ định catalog, /Size 6 nêu số lượng đối tượng, và startxref 408 cung cấp độ lệch byte của bản thân từ xref. Một trình đọc sẽ mở tệp, nhảy đến cuối, đọc startxref, tìm kiếm (seek) bảng tham chiếu chéo, và từ đó chạm đến catalog cũng như mọi thứ bên dưới nó. %%EOF đánh dấu byte cuối cùng

Hãy để một công cụ sửa các số đếm byte

Các số offset ở trên chỉ mang tính minh họa; trên thực tế chúng sẽ sai vào thời điểm bạn gõ xong, bởi vì chúng phụ thuộc vào bố cục byte chính xác của tệp. Thay vì tính toán lại, hãy viết cấu trúc với các giá trị giữ chỗ (placeholder values) và để một tiện ích xây dựng lại bảng tham chiếu chéo cũng như độ dài các luồng. Công cụ pdftk miễn phí, đa nền tảng làm được điều này trong một lần xử lý (pass):

pdftk hello-draft.pdf output hello.pdf

Nó phân tích các đối tượng của bạn, tính toán lại từng offset byte, điền vào các giá trị /Length chính xác, ghi ra một bảng xref và trailer hợp lệ, rồi phát sinh tệp hello.pdf. Mở tệp đó trong bất kỳ trình xem nào và bạn sẽ nhận được một trang với "Hello, World!" được viết bằng font Helvetica 36 point ở vị trí gần trên cùng. Qpdf cũng làm công việc tương tự, và nhiều trình xem cũng sẽ sửa chữa (repair) một tệp bị lỗi định dạng nhẹ (slightly malformed) một cách tức thời. Việc nhờ cậy vào một công cụ ở đây không phải là sự lười biếng; mà là do số học về offset là bộ phận duy nhất của định dạng mà không có nội dung khái niệm nào nhưng lại mang tỷ lệ lỗi cao nhất, do đó việc tự động hóa nó sẽ giúp cho cấu trúc vẫn là thứ mà bạn đang học

Tại sao điều này có thể nhân rộng sang các tài liệu thực tế

Không có điều gì về một bản báo cáo dài hàng trăm trang làm thay đổi hình dáng mà bạn vừa mới xây dựng. Catalog vẫn nằm ở gốc, cây trang vẫn tập hợp các trang, và mỗi trang vẫn trỏ vào các tài nguyên của nó và một luồng nội dung. Thứ phát triển thêm là bề rộng, chứ không phải xương sống: cây trang phân nhánh để trình đọc có thể bỏ qua toàn bộ các cây con (subtrees), các luồng nội dung mang theo hàng trăm toán tử thay vì năm, các font chữ được nhúng vào dưới dạng các đối tượng luồng của riêng chúng với các bảng độ rộng và bảng mã (encodings), và các hình ảnh đến dưới dạng các luồng kèm theo bộ lọc dành riêng cho hình ảnh. Các tệp hiện đại cũng có xu hướng gói (pack) nhiều đối tượng vào các luồng đối tượng bị nén và thay thế bảng xref thuần túy bằng một luồng tham chiếu chéo, đây chính là lý do tại sao khi mở một tệp PDF thực tế trong trình soạn thảo văn bản thường sẽ hiển thị một bức tường nhị phân. Mô hình nằm bên dưới lại giống hệt với mô hình trong tệp do bạn làm bằng tay. Để biết về đồ thị đối tượng rộng hơn và cách catalog, cây trang, cùng với từ điển tài nguyên liên kết với nhau xuyên suốt một tài liệu lớn hơn, chuyến tham quan chuyên sâu về cấu trúc tài liệu PDF sẽ bắt đầu từ điểm kết thúc này, và tổng quan về cấu trúc tệp sẽ bao hàm các cập nhật gia tăng (incremental updates) cũng như cách trailer xâu chuỗi xuyên suốt các bản sửa đổi (revisions)

Từ viết-bằng-tay sang một thư viện

Việc gõ các đối tượng bằng tay là một bài tập học hỏi, chứ không phải là một kỹ thuật sản xuất. Ngay khoảnh khắc bạn cần tới các font chữ thực sự, văn bản gói lại (wrapped text), hình ảnh, hoặc nhiều hơn là một trang tủn mủn (trivial page), việc ghi chép byte mà pdftk đã vá lỗi (patched) cho bạn lại trở thành toàn bộ công việc, và bạn muốn có một thư viện làm chủ nó. Năm đối tượng tương tự vẫn được viết ra, nhưng một thư viện sẽ tính toán từng offset, quản lý các từ điển font và tài nguyên, và nén các luồng nội dung mà không cần bạn phải theo dõi một byte nào. Trong Delphi và C++Builder, HotPDF Component rút gọn toàn bộ tệp này thành một số ít các lời gọi: thiết lập tài liệu, gọi BeginDoc, SetFontTextOut để đặt cùng lời chào đó, sau đó EndDoc để viết ra một catalog, cây trang, xref, và trailer một cách chuẩn xác. Việc hiểu rõ các đối tượng bên dưới mới là thứ giúp cho bạn có thể lập luận về đầu ra khi một tài liệu không kết xuất theo đúng cách bạn mong đợi