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.rejectthay vìthrow? Đ: Để giữ nhất quán ngữ nghĩa bất đồng bộ. Người gọigetPageluônawaitmộ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/#prevPageNumbersbên trong sẽ được đặt lại thànhnull, 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ùngpageIndextheo 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ượngPDFPageProxy(page === page2là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ó
#pageCachelư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ọigetPageđến cùng lúc chỉ gửi một tin nhắnGetPage」;#pageCachechỉ đượ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 = 4nghĩa là "đối tượng số 4",gen = 0là 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ể
Refthật sự: xem mã nguồn được tạo quaRef.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:postMessagecủa Web Worker dùng Structured Clone, chỉ sao chép các thuộc tính riêng có thể liệt kê.#strcủaReflà 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 publicnumvà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ạoRef, vì luồng chính chỉ cần đọcnum/genvà không bao giờ gửi chúng trở lại nguyên vẹn. Các tin nhắn sau nhưGetPageIndexhoặcSaveDocumentcủa editor sẽ tái tạoRefở 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ọitoString. Vì vậy worker tính sẵnrefStr: ref?.toString() ?? nulltrong giá trị trả về. Hai trường, hai vai trò:refcho các lời gọi cần táchnum/gen,refStrcho các tra cứu cache cần chuỗi khóa.
H: Tại sao
refStrlà"4R"mà không phải"4 0 R"?Đ:
Ref#toStringtrả về chuỗi compact được cache bên trong, khigenbằng 0 lànumR, ngược lại lànumRgen: xem mã nguồn. Phía tra ngượccachedPageNumbercũ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
GetOperatorListtại thời điểmrender. Đây là lý dogetPagecó 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ủagetViewportsẽ 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:
- 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; - 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; - Tạo
PDFPageProxyvà ghi vào#pageCache; - Ghi chính promise vào
#pagePromises.
H: Tại sao
#pageRefCachelưunewPageIndexmà không phảipageIndex?Đ: 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,
cachedPageNumbertrước tiên lấy chỉ mục trang gốc, rồi dùngpagesMapper.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
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 getterpageNumbervới giá trị_pageIndex + 1;_pageInfochính làpageInfoở trên, cả bốn getter đều đọc từ đây;commonObjsvàobjslà vùng chứa tài nguyên khi render sau này,PDFObjectssẽ được trình bày ở bài viết riêng;recordedBBoxes/imageCoordinatesgiữnullcho đế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):
PDFDocumentProxy.getPagemột dòng ủy quyền choWorkerTransport.getPage;- Kiểm tra tham số:
1là số trang hợp lệ (tổng cộng 756 trang); - Quy đổi số trang:
pageNumber(1)→pageIndex(0)→newPageIndex(0), dưới ánh xạ đồng nhất cả ba giống nhau; - Truy vấn cache
#pagePromises, lần đầu không trúng; - Gửi tin nhắn
GetPageđến worker, nhận vềrotate/ref/refStr/userUnit/view; - Ghi
#pageRefCache, tạoPDFPageProxy, ghi vào#pageCache; - Ghi promise vào
#pagePromisesvà trả về,awaitcủa demo nhận đượcpage.
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