0

PDF.js Khám phá sâu từ Demo 3: Toàn cảnh phương thức getPage

Lời nói đầu

Trong demo của bài viết đầu tiên, chúng ta đã lấy được biến pdf qua await loadingTask.promise — promise này được resolve bởi handler GetDoc ở luồng chính sau khi worker gửi trả tin nhắn GetDoc; chi tiết xem ở bài 13 — nó là một thực thể của PDFDocumentProxy.

Quay lại demo của bài thứ nhất, dòng 8-9:

const page = await pdf.getPage(1);

Lời gọi getPage

Lời gọi này chỉ truyền vào một tham số số:

Tham số Giá trị Ý nghĩa
pageNumber 1 Số trang, trang đầu tiên là 1 (1-based)

getPage trả về một Promise, resolve ra đối tượng PDFPageProxy. Bài này theo lời gọi này đi qua toàn bộ chuỗi thực thi của getPage.

Dựa trên định nghĩa PDFDocumentProxy trong api.js, luồng thực thi mã như sau.

Dòng 773-775: PDFDocumentProxy.getPage: một dòng ủy quyền

getPage(pageNumber) {
  return this._transport.getPage(pageNumber);
}

Bản thân đối tượng pdf chỉ là lớp mặt tiền, logic thực sự nằm trên this._transport — tức là thực thể WorkerTransport tạo ra ở bài 2 (task._transport).

Toàn bộ luồng dưới đây đều nằm trong WorkerTransport.getPage.

Dòng 3031-3037: Kiểm tra tham số

if (!Number.isInteger(pageNumber) || pageNumber <= 0 || pageNumber > this.pagesMapper.pagesNumber) {
  return Promise.reject(new Error('Invalid page request.'));
}

Thay giá trị của demo vào từng điều kiện:

Điều kiện Với pageNumber = 1 Kết quả
!Number.isInteger(pageNumber) Number.isInteger(1) là true, phủ định thành false Không kích hoạt
pageNumber <= 0 1 <= 0 là false Không kích hoạt
pageNumber > this.pagesMapper.pagesNumber pagesNumber là 756, 1 > 756 là false Không kích hoạt

pagesNumber là 756, được ghi từ pdfInfo.numPages khi tài liệu sẵn sàng: xem mã nguồn. PDF demo của bài này có tổng cộng 756 trang, kiểm tra pass. Nếu truyền vào 0, số âm, số thập phân, hoặc vượt quá số lượng trang, tại đây sẽ trả về promise bị reject ngay lập tức.

H: Tại sao dùng Promise.reject thay vì throw? Đ: Để giữ nhất quán ngữ nghĩa bất đồng bộ. Người gọi getPage luôn await một promise, lỗi tham số cũng đi qua cùng một kênh lỗi, người gọi không cần phải đồng thời xử lý hai trường hợp throw đồng bộ và reject bất đồng bộ.

Dòng 3038-3039: Quy đổi số trang

const pageIndex = pageNumber - 1; // 1 - 1 = 0
const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0

Hai dòng này sinh ra ba hệ thống số trang, phục vụ ba đối tượng khác nhau:

Biến Góc nhìn Cơ số Giá trị ở đây Mục đích
pageNumber Người dùng 1-based 1 Tham số getPage hướng ra ngoài
pageIndex Luồng chính 0-based 0 Khóa của #pageCache / #pagePromises
newPageIndex worker (tài liệu gốc) 0-based 0 Payload của tin nhắn GetPage

pagesMapper.getPageId(1) trả về 1. Demo chưa từng di chuyển, xóa hoặc sao chép trang, nên ánh xạ là ánh xạ đồng nhất (#pageNumberToId là null), do đó newPageIndex === pageIndex === 0. Ba giá trị chỉ tách nhánh khi tài liệu đã bị chỉnh sửa.

PagesMapper: phiên dịch giữa số trang người dùng và thứ tự trang gốc

PagesMapper được tạo với new PagesMapper() khi phân tích tham số ở bài 2: xem mã nguồn. Nó chỉ expose ra ngoài hai thứ:

  • pagesNumber: tổng số trang hiện tại. Một khi được gán, #pageNumberToId / #prevPageNumbers bên trong sẽ được đặt lại thành null, quay về ánh xạ đồng nhất;
  • getPageId(pageNumber) / getPageNumber(pageId): phiên dịch hai chiều (L462-L464, L443-L454). Dưới ánh xạ đồng nhất chúng trả về nguyên tham số đầu vào, chỉ khi có sắp xếp lại trang mới thực sự dựng bảng.

H: Tại sao cần PagesMapper làm vòng quay này? Đ: Để hỗ trợ sắp xếp lại trang. Trong trường hợp trình soạn thảo, người dùng có thể di chuyển, xóa, sao chép trang, sau đó「trang 3」không còn nhất thiết là trang 3 của tài liệu gốc. Phía worker chỉ biết thứ tự trang của tài liệu gốc, nên cái được gửi đến worker là newPageIndex; còn hai cache luồng chính dùng pageIndex theo góc nhìn người làm khóa. Dưới ánh xạ đồng nhất cả hai giống nhau, có thể bỏ qua. Lưu ý, PagesMapper không liên quan gì đến tệp PDF, trong tệp PDF không có dữ liệu này, nó thuần túy để lưu một số trạng thái khi chỉnh sửa

Một câu để phân biệt: pageIndex là khóa cache trên luồng chính, còn newPageIndex là khóa gửi đến worker.

Dòng 3041-3044: Truy vấn cache

const cachedPromise = this.#pagePromises.get(0); // pageIndex = 0
if (cachedPromise) {
  return cachedPromise;
}

#pagePromises là Map<pageIndex, Promise<PDFPageProxy>>. Lần gọi đầu tiên không trúng, tiếp tục đi xuống.

H: Hiệu ứng của cache là gì? Đ: Thực tế thử nghiệm gọi await pdf.getPage(1) hai lần liên tiếp, trả về là cùng một đối tượng PDFPageProxy (page === page2 là true). Vì lần gọi thứ hai trực tiếp trúng promise trong cache, không gửi thêm tin nhắn đến worker nữa.

Ba lớp cache: mỗi lớp một việc

WorkerTransport khai báo cả ba Map ngay trong các trường của lớp: xem mã nguồn

#pageCache = new Map();
#pagePromises = new Map();
#pageRefCache = new Map();
Trường Khóa → Giá trị Trách nhiệm Điểm sử dụng tiêu biểu
#pageCache pageIndex → PDFPageProxy Chỉ mục các đối tượng trang đã tạo Sự kiện StartRenderPage lấy đối tượng theo chỉ mục (api.js#L2805), startCleanup duyệt (api.js#L3227)
#pagePromises pageIndex → Promise<PDFPageProxy> Gộp các yêu cầu in-flight và lời gọi lặp Đọc ở đầu getPage (dòng 3041 bài này), ghi ở cuối (dòng 3067)
#pageRefCache refStr → pageIndex Tra ngược từ tham chiếu đối tượng ra chỉ mục trang getPage ghi (dòng 3054), cachedPageNumber đọc (api.js#L3245-L3255)

Cả ba có cùng vòng đời và bị xóa khi transport bị hủy: destroy() lần lượt clear() cả ba bảng (api.js#L2611-L2616).

H: Đã có #pageCache lưu đối tượng trang, tại sao vẫn cần #pagePromises? Đ: Hai thứ giải quyết hai vấn đề khác nhau. #pagePromises được ghi trước khi tin nhắn rời đi, phụ trách「nhiều lời gọi getPage đến cùng lúc chỉ gửi một tin nhắn GetPage」; #pageCache chỉ được ghi sau khi phản hồi đến, để các sự kiện nội bộ transport lấy đối tượng theo chỉ mục trang. Cache promise thay vì page chính là để phủ khoảng thời gian trước khi phản hồi đến.

Dòng 3045-3048: Gửi tin nhắn GetPage đến worker

const promise = this.messageHandler
  .sendWithPromise('GetPage', {
    pageIndex: newPageIndex, // 0
  })
  .then(pageInfo => {
    // Xử lý phản hồi xem phần tiếp theo
  });

messageHandler là MessageHandler tạo ra ở bài 2. Cơ chế giao tiếp của sendWithPromise được trình bày ở bài 6. Ở đây chỉ cần biết: nó gửi tin nhắn đến luồng worker, và trả về một promise đợi phản hồi của worker.

Khi worker nhận được GetPage: xem mã nguồn

handler.on('GetPage', async function ({ pageIndex }) {
  const page = await pdfManager.getPage(pageIndex);
  const [rotate, ref, userUnit, view] = await Promise.all([
    pdfManager.ensure(page, 'rotate'),
    pdfManager.ensure(page, 'ref'),
    pdfManager.ensure(page, 'userUnit'),
    pdfManager.ensure(page, 'view'),
  ]);
  return {
    rotate,
    ref,
    refStr: ref?.toString() ?? null,
    userUnit,
    view,
  };
});

Cách worker parse đối tượng trang bên trong được trình bày ở bài 14. Phản hồi của trang đầu tiên của demo là:

pageInfo = {
  rotate: 0,
  ref: { num: 4, gen: 0 }, // sau khi structured cloning xuyên luồng, thực thể Ref suy giảm thành đối tượng thường
  refStr: '4R', // kết quả của ref.toString() phía worker
  userUnit: 1,
  view: [0, 0, 595, 842],
};
Trường Giá trị ở đây Ý nghĩa Getter tương ứng trên PDFPageProxy
rotate 0 Góc quay theo chiều kim đồng hồ (độ) rotate
ref { num: 4, gen: 0 } Tham chiếu đối tượng (Ref entry của trang này trong file PDF) ref
refStr "4R" Dạng chuỗi của tham chiếu, dùng làm khóa cache Không có (chỉ dùng nội bộ)
userUnit 1 Hệ số quy đổi đơn vị người dùng userUnit
view [0, 0, 595, 842] Khu vực nhìn thấy (không gian người dùng PDF) view

595 × 842 của view đúng là kích thước giấy A4 với đơn vị 1/72 inch, khớp với tài liệu PDF demo.

H: ref: { num: 4, gen: 0 } là gì? Tại sao không phải là thực thể Ref?

Đ: Nó là tham chiếu gián tiếp (Indirect Reference) theo chuẩn PDF — mỗi đối tượng trong file PDF có một số (num, gen) duy nhất, và đối tượng trang cũng không ngoại lệ. num = 4 nghĩa là "đối tượng số 4", gen = 0 là số thế hệ (thường là 0 ở PDF mới; nó chỉ tăng lên sau các cập nhật gia tăng).

Ở phía worker, vốn dĩ đó là một thực thể Ref thật sự: xem mã nguồn được tạo qua Ref.get(4, 0), với một #str = '4R' riêng tư được cache bên trong. Nhưng nó không thể xuyên luồng: postMessage của Web Worker dùng Structured Clone, chỉ sao chép các thuộc tính riêng có thể liệt kê. #str của Ref là trường riêng tư — nó không được clone — và các thực thể class suy giảm thành đối tượng thường, chỉ còn hai trường public num và gen. Đó là lý do luồng chính nhận được { num: 4, gen: 0 }.

Getter PDFPageProxy.ref ở luồng chính (api.js#L1399) chỉ đơn giản trả về đối tượng thường này — nó không bao giờ tái tạo Ref, vì luồng chính chỉ cần đọc num / gen và không bao giờ gửi chúng trở lại nguyên vẹn. Các tin nhắn sau như GetPageIndex hoặc SaveDocument của editor sẽ tái tạo Ref ở phía worker từ hai trường này.

Tại sao phải tính sẵn refStr ở phía worker? Chính vì Ref#toString() bị mất sau khi xuyên luồng — luồng chính chỉ giữ một đối tượng thường và không thể gọi toString. Vì vậy worker tính sẵn refStr: ref?.toString() ?? null trong giá trị trả về. Hai trường, hai vai trò: ref cho các lời gọi cần tách num / gen, refStr cho các tra cứu cache cần chuỗi khóa.

H: Tại sao refStr là "4R" mà không phải "4 0 R"?

Đ: Ref#toString trả về chuỗi compact được cache bên trong, khi gen bằng 0 là numR, ngược lại là numRgen: xem mã nguồn. Phía tra ngược cachedPageNumber cũng ghép khóa theo ${ref.num}R, hai bên phải cùng định dạng.

H: Tại sao chỉ lấy 4 thuộc tính này?

Đ: Chúng đều là metadata của trang: góc quay, tham chiếu đối tượng, hệ số quy đổi đơn vị, khu vực nhìn thấy. Đọc chúng chỉ cần parse từ điển đối tượng trang, không đụng vào luồng nội dung trang. Việc parse nội dung thực sự (lệnh vẽ) được trì hoãn đến tin nhắn GetOperatorList tại thời điểm render. Đây là lý do getPage có thể trả về nhanh chóng.

Bốn thuộc tính này sau đó lần lượt tương ứng với các getter trên PDFPageProxy: rotate, ref, userUnit, view. Bài tiếp theo của getViewport sẽ sử dụng hai trong số chúng.

Dòng 3049-3068: Xử lý phản hồi và tạo PDFPageProxy

Khi phản hồi đến, vào callback .then:

.then(pageInfo => {
  if (this.destroyed) {
    throw new Error("Transport destroyed");
  }
  if (pageInfo.refStr) {
    this.#pageRefCache.set(pageInfo.refStr, newPageIndex); // "4R" -> 0
  }

  const page = new PDFPageProxy(
    pageIndex,          // 0
    pageInfo,
    this,
    this.pagesMapper,
    this._params.pdfBug // false
  );
  this.#pageCache.set(pageIndex, page);
  return page;
});
this.#pagePromises.set(pageIndex, promise); // dòng 3067
return promise;                             // dòng 3068

Tuần tự làm bốn việc:

  1. Kiểm tra destroyed: trong khi đợi phản hồi, tài liệu có thể đã bị hủy (loadingTask.destroy()), nếu có thì ném lỗi;
  2. Cache tra cứu ngược #pageRefCache: "4R" → 0. Để cachedPageNumber(ref) tra cứu ngược — ví dụ khi mục lục nhảy đến trang, bên trong PDF dùng tham chiếu đối tượng (4 0 R) định vị trang, cần tra cứu ngược lại ra số trang;
  3. Tạo PDFPageProxy và ghi vào #pageCache;
  4. Ghi chính promise vào #pagePromises.

H: Tại sao #pageRefCache lưu newPageIndex mà không phải pageIndex?

Đ: Vì nó ghi lại「tham chiếu đối tượng này trỏ đến trang nào của tài liệu gốc」, không liên quan đến góc nhìn người dùng. Khi tra ngược, cachedPageNumber trước tiên lấy chỉ mục trang gốc, rồi dùng pagesMapper.getPageNumber(pageIndex + 1) phiên dịch ngược ra số trang người dùng (api.js#L3245-L3255). Dưới ánh xạ đồng nhất hai giá trị tình cờ bằng nhau, nhưng về ngữ nghĩa không thể hoán đổi.

Hàm khởi tạo PDFPageProxy: chỉ gán, không parse

xem mã nguồn

constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
  this._pageIndex = pageIndex;                  // 0
  this._pageInfo = pageInfo;                    // pageInfo nhận được ở trên
  this._transport = transport;
  this._stats = pdfBug ? new StatTimer() : null; // false, không bật thống kê hiệu năng
  this._pdfBug = pdfBug;
  this.commonObjs = transport.commonObjs;       // vùng chứa tài nguyên dùng chung toàn tài liệu (phông chữ v.v.)
  this.objs = new PDFObjects();                 // vùng chứa tài nguyên riêng của trang này
  this._intentStates = new Map();               // cache trạng thái ý định render, render ở bài 5 sẽ dùng tới
  this.destroyed = false;
  this.recordedBBoxes = null;                   // vùng đã ghi nhận cho trường hợp trình soạn thảo
  this.#pagesMapper = pagesMapper;
  this.imageCoordinates = null;                 // ánh xạ tọa độ hình ảnh, chỉ có giá trị sau khi render
}

Toàn bộ hàm khởi tạo không có một await nào, cũng không có bất kỳ thao tác parse nào, chỉ là chuyển tham chiếu và đặt chỗ:

  • _pageIndex được expose ra ngoài qua getter pageNumber với giá trị _pageIndex + 1;
  • _pageInfo chính là pageInfo ở trên, cả bốn getter đều đọc từ đây;
  • commonObjs và objs là vùng chứa tài nguyên khi render sau này, PDFObjects sẽ được trình bày ở bài viết riêng;
  • recordedBBoxes / imageCoordinates giữ null cho đến lần render đầu tiên.

Đây cũng là lý do chi phí phía luồng chính của getPage rất nhỏ: công việc nặng hoàn toàn nằm ở luồng worker, luồng chính chỉ lắp ráp đối tượng.

Đến đây, promise của getPage(1) resolve ra page, await trong demo nhận được nó.

Tổng kết

Trong phần này, chúng ta theo dõi demo đi qua toàn bộ lời gọi getPage(1):

  1. PDFDocumentProxy.getPage một dòng ủy quyền cho WorkerTransport.getPage;
  2. Kiểm tra tham số: 1 là số trang hợp lệ (tổng cộng 756 trang);
  3. Quy đổi số trang: pageNumber(1) → pageIndex(0) → newPageIndex(0), dưới ánh xạ đồng nhất cả ba giống nhau;
  4. Truy vấn cache #pagePromises, lần đầu không trúng;
  5. Gửi tin nhắn GetPage đến worker, nhận về rotate / ref / refStr / userUnit / view;
  6. Ghi #pageRefCache, tạo PDFPageProxy, ghi vào #pageCache;
  7. Ghi promise vào #pagePromises và trả về, await của demo nhận được page.

Sau khi có page, bước tiếp theo của demo là page.getViewport({ scale: 0.5 }) — nó sẽ dùng tới view và userUnit trong pageInfo của bài này. Bài tiếp theo sẽ trình bày.


Về tôi: Người sáng tạo StreamPDF, tập trung vào nghiên cứu và phát triển trong lĩnh vực AI và PDF.

Liên kết bài viết gốc: PDF.js Khám phá sâu từ Demo 3: Toàn cảnh phương thức getPage


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí