pdf_thumbnail_view.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. /* Copyright 2012 Mozilla Foundation
  2. *
  3. * Licensed under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License.
  5. * You may obtain a copy of the License at
  6. *
  7. * http://www.apache.org/licenses/LICENSE-2.0
  8. *
  9. * Unless required by applicable law or agreed to in writing, software
  10. * distributed under the License is distributed on an "AS IS" BASIS,
  11. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. * See the License for the specific language governing permissions and
  13. * limitations under the License.
  14. */
  15. /** @typedef {import("./interfaces").IL10n} IL10n */
  16. /** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
  17. /** @typedef {import("./interfaces").IRenderableView} IRenderableView */
  18. // eslint-disable-next-line max-len
  19. /** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */
  20. import { OutputScale, RenderingStates } from "./ui_utils.js";
  21. import { RenderingCancelledException } from "pdfjs-lib";
  22. const DRAW_UPSCALE_FACTOR = 2; // See comment in `PDFThumbnailView.draw` below.
  23. const MAX_NUM_SCALING_STEPS = 3;
  24. const THUMBNAIL_CANVAS_BORDER_WIDTH = 1; // px
  25. const THUMBNAIL_WIDTH = 98; // px
  26. /**
  27. * @typedef {Object} PDFThumbnailViewOptions
  28. * @property {HTMLDivElement} container - The viewer element.
  29. * @property {number} id - The thumbnail's unique ID (normally its number).
  30. * @property {PageViewport} defaultViewport - The page viewport.
  31. * @property {Promise<OptionalContentConfig>} [optionalContentConfigPromise] -
  32. * A promise that is resolved with an {@link OptionalContentConfig} instance.
  33. * The default value is `null`.
  34. * @property {IPDFLinkService} linkService - The navigation/linking service.
  35. * @property {PDFRenderingQueue} renderingQueue - The rendering queue object.
  36. * @property {IL10n} l10n - Localization service.
  37. * @property {Object} [pageColors] - Overwrites background and foreground colors
  38. * with user defined ones in order to improve readability in high contrast
  39. * mode.
  40. */
  41. class TempImageFactory {
  42. static #tempCanvas = null;
  43. static getCanvas(width, height) {
  44. const tempCanvas = (this.#tempCanvas ||= document.createElement("canvas"));
  45. tempCanvas.width = width;
  46. tempCanvas.height = height;
  47. // Since this is a temporary canvas, we need to fill it with a white
  48. // background ourselves. `_getPageDrawContext` uses CSS rules for this.
  49. const ctx = tempCanvas.getContext("2d", { alpha: false });
  50. ctx.save();
  51. ctx.fillStyle = "rgb(255, 255, 255)";
  52. ctx.fillRect(0, 0, width, height);
  53. ctx.restore();
  54. return [tempCanvas, tempCanvas.getContext("2d")];
  55. }
  56. static destroyCanvas() {
  57. const tempCanvas = this.#tempCanvas;
  58. if (tempCanvas) {
  59. // Zeroing the width and height causes Firefox to release graphics
  60. // resources immediately, which can greatly reduce memory consumption.
  61. tempCanvas.width = 0;
  62. tempCanvas.height = 0;
  63. }
  64. this.#tempCanvas = null;
  65. }
  66. }
  67. /**
  68. * @implements {IRenderableView}
  69. */
  70. class PDFThumbnailView {
  71. /**
  72. * @param {PDFThumbnailViewOptions} options
  73. */
  74. constructor({
  75. container,
  76. id,
  77. defaultViewport,
  78. optionalContentConfigPromise,
  79. linkService,
  80. renderingQueue,
  81. l10n,
  82. pageColors,
  83. }) {
  84. this.id = id;
  85. this.renderingId = "thumbnail" + id;
  86. this.pageLabel = null;
  87. this.pdfPage = null;
  88. this.rotation = 0;
  89. this.viewport = defaultViewport;
  90. this.pdfPageRotate = defaultViewport.rotation;
  91. this._optionalContentConfigPromise = optionalContentConfigPromise || null;
  92. this.pageColors = pageColors || null;
  93. this.linkService = linkService;
  94. this.renderingQueue = renderingQueue;
  95. this.renderTask = null;
  96. this.renderingState = RenderingStates.INITIAL;
  97. this.resume = null;
  98. const pageWidth = this.viewport.width,
  99. pageHeight = this.viewport.height,
  100. pageRatio = pageWidth / pageHeight;
  101. this.canvasWidth = THUMBNAIL_WIDTH;
  102. this.canvasHeight = (this.canvasWidth / pageRatio) | 0;
  103. this.scale = this.canvasWidth / pageWidth;
  104. this.l10n = l10n;
  105. const anchor = document.createElement("a");
  106. anchor.href = linkService.getAnchorUrl("#page=" + id);
  107. this._thumbPageTitle.then(msg => {
  108. anchor.title = msg;
  109. });
  110. anchor.onclick = function () {
  111. linkService.goToPage(id);
  112. return false;
  113. };
  114. this.anchor = anchor;
  115. const div = document.createElement("div");
  116. div.className = "thumbnail";
  117. div.setAttribute("data-page-number", this.id);
  118. this.div = div;
  119. const ring = document.createElement("div");
  120. ring.className = "thumbnailSelectionRing";
  121. const borderAdjustment = 2 * THUMBNAIL_CANVAS_BORDER_WIDTH;
  122. ring.style.width = this.canvasWidth + borderAdjustment + "px";
  123. ring.style.height = this.canvasHeight + borderAdjustment + "px";
  124. this.ring = ring;
  125. div.append(ring);
  126. anchor.append(div);
  127. container.append(anchor);
  128. }
  129. setPdfPage(pdfPage) {
  130. this.pdfPage = pdfPage;
  131. this.pdfPageRotate = pdfPage.rotate;
  132. const totalRotation = (this.rotation + this.pdfPageRotate) % 360;
  133. this.viewport = pdfPage.getViewport({ scale: 1, rotation: totalRotation });
  134. this.reset();
  135. }
  136. reset() {
  137. this.cancelRendering();
  138. this.renderingState = RenderingStates.INITIAL;
  139. const pageWidth = this.viewport.width,
  140. pageHeight = this.viewport.height,
  141. pageRatio = pageWidth / pageHeight;
  142. this.canvasHeight = (this.canvasWidth / pageRatio) | 0;
  143. this.scale = this.canvasWidth / pageWidth;
  144. this.div.removeAttribute("data-loaded");
  145. const ring = this.ring;
  146. ring.textContent = ""; // Remove the thumbnail from the DOM.
  147. const borderAdjustment = 2 * THUMBNAIL_CANVAS_BORDER_WIDTH;
  148. ring.style.width = this.canvasWidth + borderAdjustment + "px";
  149. ring.style.height = this.canvasHeight + borderAdjustment + "px";
  150. if (this.canvas) {
  151. // Zeroing the width and height causes Firefox to release graphics
  152. // resources immediately, which can greatly reduce memory consumption.
  153. this.canvas.width = 0;
  154. this.canvas.height = 0;
  155. delete this.canvas;
  156. }
  157. if (this.image) {
  158. this.image.removeAttribute("src");
  159. delete this.image;
  160. }
  161. }
  162. update({ rotation = null }) {
  163. if (typeof rotation === "number") {
  164. this.rotation = rotation; // The rotation may be zero.
  165. }
  166. const totalRotation = (this.rotation + this.pdfPageRotate) % 360;
  167. this.viewport = this.viewport.clone({
  168. scale: 1,
  169. rotation: totalRotation,
  170. });
  171. this.reset();
  172. }
  173. /**
  174. * PLEASE NOTE: Most likely you want to use the `this.reset()` method,
  175. * rather than calling this one directly.
  176. */
  177. cancelRendering() {
  178. if (this.renderTask) {
  179. this.renderTask.cancel();
  180. this.renderTask = null;
  181. }
  182. this.resume = null;
  183. }
  184. /**
  185. * @private
  186. */
  187. _getPageDrawContext(upscaleFactor = 1) {
  188. // Keep the no-thumbnail outline visible, i.e. `data-loaded === false`,
  189. // until rendering/image conversion is complete, to avoid display issues.
  190. const canvas = document.createElement("canvas");
  191. const ctx = canvas.getContext("2d", { alpha: false });
  192. const outputScale = new OutputScale();
  193. canvas.width = (upscaleFactor * this.canvasWidth * outputScale.sx) | 0;
  194. canvas.height = (upscaleFactor * this.canvasHeight * outputScale.sy) | 0;
  195. const transform = outputScale.scaled
  196. ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0]
  197. : null;
  198. return { ctx, canvas, transform };
  199. }
  200. /**
  201. * @private
  202. */
  203. _convertCanvasToImage(canvas) {
  204. if (this.renderingState !== RenderingStates.FINISHED) {
  205. throw new Error("_convertCanvasToImage: Rendering has not finished.");
  206. }
  207. const reducedCanvas = this._reduceImage(canvas);
  208. const image = document.createElement("img");
  209. image.className = "thumbnailImage";
  210. this._thumbPageCanvas.then(msg => {
  211. image.setAttribute("aria-label", msg);
  212. });
  213. image.style.width = this.canvasWidth + "px";
  214. image.style.height = this.canvasHeight + "px";
  215. image.src = reducedCanvas.toDataURL();
  216. this.image = image;
  217. this.div.setAttribute("data-loaded", true);
  218. this.ring.append(image);
  219. // Zeroing the width and height causes Firefox to release graphics
  220. // resources immediately, which can greatly reduce memory consumption.
  221. reducedCanvas.width = 0;
  222. reducedCanvas.height = 0;
  223. }
  224. draw() {
  225. if (this.renderingState !== RenderingStates.INITIAL) {
  226. console.error("Must be in new state before drawing");
  227. return Promise.resolve();
  228. }
  229. const { pdfPage } = this;
  230. if (!pdfPage) {
  231. this.renderingState = RenderingStates.FINISHED;
  232. return Promise.reject(new Error("pdfPage is not loaded"));
  233. }
  234. this.renderingState = RenderingStates.RUNNING;
  235. const finishRenderTask = async (error = null) => {
  236. // The renderTask may have been replaced by a new one, so only remove
  237. // the reference to the renderTask if it matches the one that is
  238. // triggering this callback.
  239. if (renderTask === this.renderTask) {
  240. this.renderTask = null;
  241. }
  242. if (error instanceof RenderingCancelledException) {
  243. return;
  244. }
  245. this.renderingState = RenderingStates.FINISHED;
  246. this._convertCanvasToImage(canvas);
  247. if (error) {
  248. throw error;
  249. }
  250. };
  251. // Render the thumbnail at a larger size and downsize the canvas (similar
  252. // to `setImage`), to improve consistency between thumbnails created by
  253. // the `draw` and `setImage` methods (fixes issue 8233).
  254. // NOTE: To primarily avoid increasing memory usage too much, but also to
  255. // reduce downsizing overhead, we purposely limit the up-scaling factor.
  256. const { ctx, canvas, transform } =
  257. this._getPageDrawContext(DRAW_UPSCALE_FACTOR);
  258. const drawViewport = this.viewport.clone({
  259. scale: DRAW_UPSCALE_FACTOR * this.scale,
  260. });
  261. const renderContinueCallback = cont => {
  262. if (!this.renderingQueue.isHighestPriority(this)) {
  263. this.renderingState = RenderingStates.PAUSED;
  264. this.resume = () => {
  265. this.renderingState = RenderingStates.RUNNING;
  266. cont();
  267. };
  268. return;
  269. }
  270. cont();
  271. };
  272. const renderContext = {
  273. canvasContext: ctx,
  274. transform,
  275. viewport: drawViewport,
  276. optionalContentConfigPromise: this._optionalContentConfigPromise,
  277. pageColors: this.pageColors,
  278. };
  279. const renderTask = (this.renderTask = pdfPage.render(renderContext));
  280. renderTask.onContinue = renderContinueCallback;
  281. const resultPromise = renderTask.promise.then(
  282. function () {
  283. return finishRenderTask(null);
  284. },
  285. function (error) {
  286. return finishRenderTask(error);
  287. }
  288. );
  289. resultPromise.finally(() => {
  290. // Zeroing the width and height causes Firefox to release graphics
  291. // resources immediately, which can greatly reduce memory consumption.
  292. canvas.width = 0;
  293. canvas.height = 0;
  294. // Only trigger cleanup, once rendering has finished, when the current
  295. // pageView is *not* cached on the `BaseViewer`-instance.
  296. const pageCached = this.linkService.isPageCached(this.id);
  297. if (!pageCached) {
  298. this.pdfPage?.cleanup();
  299. }
  300. });
  301. return resultPromise;
  302. }
  303. setImage(pageView) {
  304. if (this.renderingState !== RenderingStates.INITIAL) {
  305. return;
  306. }
  307. const { thumbnailCanvas: canvas, pdfPage, scale } = pageView;
  308. if (!canvas) {
  309. return;
  310. }
  311. if (!this.pdfPage) {
  312. this.setPdfPage(pdfPage);
  313. }
  314. if (scale < this.scale) {
  315. // Avoid upscaling the image, since that makes the thumbnail look blurry.
  316. return;
  317. }
  318. this.renderingState = RenderingStates.FINISHED;
  319. this._convertCanvasToImage(canvas);
  320. }
  321. /**
  322. * @private
  323. */
  324. _reduceImage(img) {
  325. const { ctx, canvas } = this._getPageDrawContext();
  326. if (img.width <= 2 * canvas.width) {
  327. ctx.drawImage(
  328. img,
  329. 0,
  330. 0,
  331. img.width,
  332. img.height,
  333. 0,
  334. 0,
  335. canvas.width,
  336. canvas.height
  337. );
  338. return canvas;
  339. }
  340. // drawImage does an awful job of rescaling the image, doing it gradually.
  341. let reducedWidth = canvas.width << MAX_NUM_SCALING_STEPS;
  342. let reducedHeight = canvas.height << MAX_NUM_SCALING_STEPS;
  343. const [reducedImage, reducedImageCtx] = TempImageFactory.getCanvas(
  344. reducedWidth,
  345. reducedHeight
  346. );
  347. while (reducedWidth > img.width || reducedHeight > img.height) {
  348. reducedWidth >>= 1;
  349. reducedHeight >>= 1;
  350. }
  351. reducedImageCtx.drawImage(
  352. img,
  353. 0,
  354. 0,
  355. img.width,
  356. img.height,
  357. 0,
  358. 0,
  359. reducedWidth,
  360. reducedHeight
  361. );
  362. while (reducedWidth > 2 * canvas.width) {
  363. reducedImageCtx.drawImage(
  364. reducedImage,
  365. 0,
  366. 0,
  367. reducedWidth,
  368. reducedHeight,
  369. 0,
  370. 0,
  371. reducedWidth >> 1,
  372. reducedHeight >> 1
  373. );
  374. reducedWidth >>= 1;
  375. reducedHeight >>= 1;
  376. }
  377. ctx.drawImage(
  378. reducedImage,
  379. 0,
  380. 0,
  381. reducedWidth,
  382. reducedHeight,
  383. 0,
  384. 0,
  385. canvas.width,
  386. canvas.height
  387. );
  388. return canvas;
  389. }
  390. get _thumbPageTitle() {
  391. return this.l10n.get("thumb_page_title", {
  392. page: this.pageLabel ?? this.id,
  393. });
  394. }
  395. get _thumbPageCanvas() {
  396. return this.l10n.get("thumb_page_canvas", {
  397. page: this.pageLabel ?? this.id,
  398. });
  399. }
  400. /**
  401. * @param {string|null} label
  402. */
  403. setPageLabel(label) {
  404. this.pageLabel = typeof label === "string" ? label : null;
  405. this._thumbPageTitle.then(msg => {
  406. this.anchor.title = msg;
  407. });
  408. if (this.renderingState !== RenderingStates.FINISHED) {
  409. return;
  410. }
  411. this._thumbPageCanvas.then(msg => {
  412. this.image?.setAttribute("aria-label", msg);
  413. });
  414. }
  415. }
  416. export { PDFThumbnailView, TempImageFactory };