Add the current loading percentage to the onPassword callback

The percentage calculation is currently "spread out" across various viewer functionality, which we can avoid by having the API handle that instead.

Also, remove the `this.#lastProgress` special-case[1] and just register a "normal" `fullReader.onProgress` callback unconditionally. Once `headersReady` is resolved the callback can simply be removed when not needed, since the "worst" thing that could theoretically happen is that the loadingBar (in the viewer) updates sooner this way. In practice though, since `fullReader.read` cannot return data until `headersReady` is resolved, this change is not actually observable in the API.

---

[1] This was added in PR 8617, close to a decade ago, but it's not obvious to me that it was ever necessary to implement it that way.
This commit is contained in:
Jonas Jenwald 2026-01-30 08:02:24 +01:00
parent 4ca205bac3
commit ecb09d62fc
6 changed files with 92 additions and 111 deletions

View File

@ -46,19 +46,13 @@ const PDFViewerApplication = {
* @returns {Promise} - Returns the promise, which is resolved when document * @returns {Promise} - Returns the promise, which is resolved when document
* is opened. * is opened.
*/ */
open(params) { async open(params) {
if (this.pdfLoadingTask) { if (this.pdfLoadingTask) {
// We need to destroy already opened document // We need to destroy already opened document.
return this.close().then( await this.close();
function () {
// ... and repeat the open() call.
return this.open(params);
}.bind(this)
);
} }
const url = params.url; const { url } = params;
const self = this;
this.setTitleUsingUrl(url); this.setTitleUsingUrl(url);
// Loading document. // Loading document.
@ -70,24 +64,22 @@ const PDFViewerApplication = {
}); });
this.pdfLoadingTask = loadingTask; this.pdfLoadingTask = loadingTask;
loadingTask.onProgress = function (progressData) { loadingTask.onProgress = evt => this.progress(evt.percent);
self.progress(progressData.loaded / progressData.total);
};
return loadingTask.promise.then( return loadingTask.promise.then(
function (pdfDocument) { pdfDocument => {
// Document loaded, specifying document for the viewer. // Document loaded, specifying document for the viewer.
self.pdfDocument = pdfDocument; this.pdfDocument = pdfDocument;
self.pdfViewer.setDocument(pdfDocument); this.pdfViewer.setDocument(pdfDocument);
self.pdfLinkService.setDocument(pdfDocument); this.pdfLinkService.setDocument(pdfDocument);
self.pdfHistory.initialize({ this.pdfHistory.initialize({
fingerprint: pdfDocument.fingerprints[0], fingerprint: pdfDocument.fingerprints[0],
}); });
self.loadingBar.hide(); this.loadingBar.hide();
self.setTitleUsingMetadata(pdfDocument); this.setTitleUsingMetadata(pdfDocument);
}, },
function (reason) { reason => {
let key = "pdfjs-loading-error"; let key = "pdfjs-loading-error";
if (reason instanceof pdfjsLib.InvalidPDFException) { if (reason instanceof pdfjsLib.InvalidPDFException) {
key = "pdfjs-invalid-file-error"; key = "pdfjs-invalid-file-error";
@ -96,10 +88,10 @@ const PDFViewerApplication = {
? "pdfjs-missing-file-error" ? "pdfjs-missing-file-error"
: "pdfjs-unexpected-response-error"; : "pdfjs-unexpected-response-error";
} }
self.l10n.get(key).then(msg => { this.l10n.get(key).then(msg => {
self.error(msg, { message: reason?.message }); this.error(msg, { message: reason.message });
}); });
self.loadingBar.hide(); this.loadingBar.hide();
} }
); );
}, },
@ -109,9 +101,9 @@ const PDFViewerApplication = {
* @returns {Promise} - Returns the promise, which is resolved when all * @returns {Promise} - Returns the promise, which is resolved when all
* destruction is completed. * destruction is completed.
*/ */
close() { async close() {
if (!this.pdfLoadingTask) { if (!this.pdfLoadingTask) {
return Promise.resolve(); return;
} }
const promise = this.pdfLoadingTask.destroy(); const promise = this.pdfLoadingTask.destroy();
@ -128,7 +120,7 @@ const PDFViewerApplication = {
} }
} }
return promise; await promise;
}, },
get loadingBar() { get loadingBar() {
@ -152,48 +144,36 @@ const PDFViewerApplication = {
this.setTitle(title); this.setTitle(title);
}, },
setTitleUsingMetadata(pdfDocument) { async setTitleUsingMetadata(pdfDocument) {
const self = this; const { info, metadata } = await pdfDocument.getMetadata();
pdfDocument.getMetadata().then(function (data) { this.documentInfo = info;
const info = data.info, this.metadata = metadata;
metadata = data.metadata;
self.documentInfo = info;
self.metadata = metadata;
// Provides some basic debug information // Provides some basic debug information
console.log( console.log(
"PDF " + `PDF ${pdfDocument.fingerprints[0]} [${info.PDFFormatVersion} ` +
pdfDocument.fingerprints[0] + `${(metadata?.get("pdf:producer") || info.Producer || "-").trim()} / ` +
" [" + `${(metadata?.get("xmp:creatortool") || info.Creator || "-").trim()}` +
info.PDFFormatVersion + `] (PDF.js: ${pdfjsLib.version || "?"} [${pdfjsLib.build || "?"}])`
" " + );
(info.Producer || "-").trim() +
" / " +
(info.Creator || "-").trim() +
"]" +
" (PDF.js: " +
(pdfjsLib.version || "-") +
")"
);
let pdfTitle; let pdfTitle;
if (metadata && metadata.has("dc:title")) { if (metadata && metadata.has("dc:title")) {
const title = metadata.get("dc:title"); const title = metadata.get("dc:title");
// Ghostscript sometimes returns 'Untitled', so prevent setting the // Ghostscript sometimes returns 'Untitled', so prevent setting the
// title to 'Untitled. // title to 'Untitled.
if (title !== "Untitled") { if (title !== "Untitled") {
pdfTitle = title; pdfTitle = title;
}
} }
}
if (!pdfTitle && info && info.Title) { if (!pdfTitle && info && info.Title) {
pdfTitle = info.Title; pdfTitle = info.Title;
} }
if (pdfTitle) { if (pdfTitle) {
self.setTitle(pdfTitle + " - " + document.title); this.setTitle(pdfTitle + " - " + document.title);
} }
});
}, },
setTitle: function pdfViewSetTitle(title) { setTitle: function pdfViewSetTitle(title) {
@ -223,8 +203,7 @@ const PDFViewerApplication = {
console.error(`${message}\n\n${moreInfoText.join("\n")}`); console.error(`${message}\n\n${moreInfoText.join("\n")}`);
}, },
progress: function pdfViewProgress(level) { progress(percent) {
const percent = Math.round(level * 100);
// Updating the bar if value increases. // Updating the bar if value increases.
if (percent > this.loadingBar.percent || isNaN(percent)) { if (percent > this.loadingBar.percent || isNaN(percent)) {
this.loadingBar.percent = percent; this.loadingBar.percent = percent;

View File

@ -25,6 +25,7 @@ import {
getVerbosityLevel, getVerbosityLevel,
info, info,
isNodeJS, isNodeJS,
MathClamp,
RenderingIntentFlag, RenderingIntentFlag,
setVerbosityLevel, setVerbosityLevel,
shadow, shadow,
@ -525,6 +526,8 @@ function getDocument(src = {}) {
* @typedef {Object} OnProgressParameters * @typedef {Object} OnProgressParameters
* @property {number} loaded - Currently loaded number of bytes. * @property {number} loaded - Currently loaded number of bytes.
* @property {number} total - Total number of bytes in the PDF file. * @property {number} total - Total number of bytes in the PDF file.
* @property {number} percent - Currently loaded percentage, as an integer value
* in the [0, 100] range. If `total` is undefined, the percentage is `NaN`.
*/ */
/** /**
@ -2396,8 +2399,6 @@ class WorkerTransport {
#fullReader = null; #fullReader = null;
#lastProgress = null;
#methodPromises = new Map(); #methodPromises = new Map();
#networkStream = null; #networkStream = null;
@ -2492,6 +2493,14 @@ class WorkerTransport {
return promise; return promise;
} }
#onProgress({ loaded, total }) {
this.loadingTask.onProgress?.({
loaded,
total,
percent: MathClamp(Math.round((loaded / total) * 100), 0, 100),
});
}
get annotationStorage() { get annotationStorage() {
return shadow(this, "annotationStorage", new AnnotationStorage()); return shadow(this, "annotationStorage", new AnnotationStorage());
} }
@ -2624,12 +2633,10 @@ class WorkerTransport {
"GetReader - no `BasePDFStream` instance available." "GetReader - no `BasePDFStream` instance available."
); );
this.#fullReader = this.#networkStream.getFullReader(); this.#fullReader = this.#networkStream.getFullReader();
this.#fullReader.onProgress = evt => { // If stream or range turn out to be disabled, once `headersReady` is
this.#lastProgress = { // resolved, this is our only way to report loading progress.
loaded: evt.loaded, this.#fullReader.onProgress = evt => this.#onProgress(evt);
total: evt.total,
};
};
sink.onPull = () => { sink.onPull = () => {
this.#fullReader this.#fullReader
.read() .read()
@ -2669,20 +2676,9 @@ class WorkerTransport {
const { isStreamingSupported, isRangeSupported, contentLength } = const { isStreamingSupported, isRangeSupported, contentLength } =
this.#fullReader; this.#fullReader;
// If stream or range are disabled, it's our only way to report if (isStreamingSupported && isRangeSupported) {
// loading progress. this.#fullReader.onProgress = null; // See comment in "GetReader" above.
if (!isStreamingSupported || !isRangeSupported) {
if (this.#lastProgress) {
loadingTask.onProgress?.(this.#lastProgress);
}
this.#fullReader.onProgress = evt => {
loadingTask.onProgress?.({
loaded: evt.loaded,
total: evt.total,
});
};
} }
return { isStreamingSupported, isRangeSupported, contentLength }; return { isStreamingSupported, isRangeSupported, contentLength };
}); });
@ -2779,10 +2775,7 @@ class WorkerTransport {
messageHandler.on("DataLoaded", data => { messageHandler.on("DataLoaded", data => {
// For consistency: Ensure that progress is always reported when the // For consistency: Ensure that progress is always reported when the
// entire PDF file has been loaded, regardless of how it was fetched. // entire PDF file has been loaded, regardless of how it was fetched.
loadingTask.onProgress?.({ this.#onProgress({ loaded: data.length, total: data.length });
loaded: data.length,
total: data.length,
});
this.downloadInfoCapability.resolve(data); this.downloadInfoCapability.resolve(data);
}); });
@ -2905,10 +2898,7 @@ class WorkerTransport {
if (this.destroyed) { if (this.destroyed) {
return; // Ignore any pending requests if the worker was terminated. return; // Ignore any pending requests if the worker was terminated.
} }
loadingTask.onProgress?.({ this.#onProgress(data);
loaded: data.loaded,
total: data.total,
});
}); });
messageHandler.on("FetchBinaryData", async data => { messageHandler.on("FetchBinaryData", async data => {

View File

@ -160,14 +160,18 @@ describe("api", function () {
progressReportedCapability.resolve(progressData); progressReportedCapability.resolve(progressData);
}; };
const data = await Promise.all([ const [pdfDoc, progress] = await Promise.all([
progressReportedCapability.promise,
loadingTask.promise, loadingTask.promise,
progressReportedCapability.promise,
]); ]);
expect(data[0].loaded / data[0].total >= 0).toEqual(true); expect(pdfDoc instanceof PDFDocumentProxy).toEqual(true);
expect(data[1] instanceof PDFDocumentProxy).toEqual(true); expect(pdfDoc.loadingTask).toBe(loadingTask);
expect(loadingTask).toEqual(data[1].loadingTask);
expect(progress.loaded).toBeGreaterThanOrEqual(0);
expect(progress.total).toEqual(basicApiFileLength);
expect(progress.percent).toBeGreaterThanOrEqual(0);
expect(progress.percent).toBeLessThanOrEqual(100);
await loadingTask.destroy(); await loadingTask.destroy();
}); });
@ -218,12 +222,17 @@ describe("api", function () {
progressReportedCapability.resolve(data); progressReportedCapability.resolve(data);
}; };
const data = await Promise.all([ const [pdfDoc, progress] = await Promise.all([
loadingTask.promise, loadingTask.promise,
progressReportedCapability.promise, progressReportedCapability.promise,
]); ]);
expect(data[0] instanceof PDFDocumentProxy).toEqual(true);
expect(data[1].loaded / data[1].total).toEqual(1); expect(pdfDoc instanceof PDFDocumentProxy).toEqual(true);
expect(pdfDoc.loadingTask).toBe(loadingTask);
expect(progress.loaded).toEqual(basicApiFileLength);
expect(progress.total).toEqual(basicApiFileLength);
expect(progress.percent).toEqual(100);
// Check that the TypedArray was transferred. // Check that the TypedArray was transferred.
expect(typedArrayPdf.length).toEqual(0); expect(typedArrayPdf.length).toEqual(0);

View File

@ -1236,9 +1236,7 @@ const PDFViewerApplication = {
this.passwordPrompt.open(); this.passwordPrompt.open();
}; };
loadingTask.onProgress = ({ loaded, total }) => { loadingTask.onProgress = evt => this.progress(evt.percent);
this.progress(loaded / total);
};
return loadingTask.promise.then( return loadingTask.promise.then(
pdfDocument => { pdfDocument => {
@ -1374,8 +1372,7 @@ const PDFViewerApplication = {
return message; return message;
}, },
progress(level) { progress(percent) {
const percent = Math.round(level * 100);
// When we transition from full request to range requests, it's possible // When we transition from full request to range requests, it's possible
// that we discard some of the loaded data. This can cause the loading // that we discard some of the loaded data. This can cause the loading
// bar to move backwards. So prevent this by only updating the bar if it // bar to move backwards. So prevent this by only updating the bar if it

View File

@ -13,7 +13,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { isPdfFile, PDFDataRangeTransport } from "pdfjs-lib"; import { isPdfFile, MathClamp, PDFDataRangeTransport } from "pdfjs-lib";
import { AppOptions } from "./app_options.js"; import { AppOptions } from "./app_options.js";
import { BaseExternalServices } from "./external_services.js"; import { BaseExternalServices } from "./external_services.js";
import { BasePreferences } from "./preferences.js"; import { BasePreferences } from "./preferences.js";
@ -627,7 +627,13 @@ class ExternalServices extends BaseExternalServices {
pdfDataRangeTransport?.onDataProgressiveDone(); pdfDataRangeTransport?.onDataProgressiveDone();
break; break;
case "progress": case "progress":
viewerApp.progress(args.loaded / args.total); const percent = MathClamp(
Math.round((args.loaded / args.total) * 100),
0,
100
);
viewerApp.progress(percent);
break; break;
case "complete": case "complete":
if (!args.data) { if (!args.data) {

View File

@ -709,7 +709,7 @@ class ProgressBar {
} }
set percent(val) { set percent(val) {
this.#percent = MathClamp(val, 0, 100); this.#percent = val;
if (isNaN(val)) { if (isNaN(val)) {
this.#classList.add("indeterminate"); this.#classList.add("indeterminate");