text_layer_builder.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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. import { getGlobalEventBus } from './ui_utils';
  16. import { renderTextLayer } from 'pdfjs-lib';
  17. const EXPAND_DIVS_TIMEOUT = 300; // ms
  18. /**
  19. * @typedef {Object} TextLayerBuilderOptions
  20. * @property {HTMLDivElement} textLayerDiv - The text layer container.
  21. * @property {EventBus} eventBus - The application event bus.
  22. * @property {number} pageIndex - The page index.
  23. * @property {PageViewport} viewport - The viewport of the text layer.
  24. * @property {PDFFindController} findController
  25. * @property {boolean} enhanceTextSelection - Option to turn on improved
  26. * text selection.
  27. */
  28. /**
  29. * The text layer builder provides text selection functionality for the PDF.
  30. * It does this by creating overlay divs over the PDF's text. These divs
  31. * contain text that matches the PDF text they are overlaying. This object
  32. * also provides a way to highlight text that is being searched for.
  33. */
  34. class TextLayerBuilder {
  35. constructor({ textLayerDiv, eventBus, pageIndex, viewport,
  36. findController = null, enhanceTextSelection = false, }) {
  37. this.textLayerDiv = textLayerDiv;
  38. this.eventBus = eventBus || getGlobalEventBus();
  39. this.textContent = null;
  40. this.textContentItemsStr = [];
  41. this.textContentStream = null;
  42. this.renderingDone = false;
  43. this.pageIdx = pageIndex;
  44. this.pageNumber = this.pageIdx + 1;
  45. this.matches = [];
  46. this.viewport = viewport;
  47. this.textDivs = [];
  48. this.findController = findController;
  49. this.textLayerRenderTask = null;
  50. this.enhanceTextSelection = enhanceTextSelection;
  51. this._onUpdateTextLayerMatches = null;
  52. this._bindMouse();
  53. }
  54. /**
  55. * @private
  56. */
  57. _finishRendering() {
  58. this.renderingDone = true;
  59. if (!this.enhanceTextSelection) {
  60. let endOfContent = document.createElement('div');
  61. endOfContent.className = 'endOfContent';
  62. this.textLayerDiv.appendChild(endOfContent);
  63. }
  64. this.eventBus.dispatch('textlayerrendered', {
  65. source: this,
  66. pageNumber: this.pageNumber,
  67. numTextDivs: this.textDivs.length,
  68. });
  69. }
  70. /**
  71. * Renders the text layer.
  72. *
  73. * @param {number} timeout - (optional) wait for a specified amount of
  74. * milliseconds before rendering
  75. */
  76. render(timeout = 0) {
  77. if (!(this.textContent || this.textContentStream) || this.renderingDone) {
  78. return;
  79. }
  80. this.cancel();
  81. this.textDivs = [];
  82. let textLayerFrag = document.createDocumentFragment();
  83. this.textLayerRenderTask = renderTextLayer({
  84. textContent: this.textContent,
  85. textContentStream: this.textContentStream,
  86. container: textLayerFrag,
  87. viewport: this.viewport,
  88. textDivs: this.textDivs,
  89. textContentItemsStr: this.textContentItemsStr,
  90. timeout,
  91. enhanceTextSelection: this.enhanceTextSelection,
  92. });
  93. this.textLayerRenderTask.promise.then(() => {
  94. this.textLayerDiv.appendChild(textLayerFrag);
  95. this._finishRendering();
  96. this._updateMatches();
  97. }, function (reason) {
  98. // Cancelled or failed to render text layer; skipping errors.
  99. });
  100. if (!this._onUpdateTextLayerMatches) {
  101. this._onUpdateTextLayerMatches = (evt) => {
  102. if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) {
  103. this._updateMatches();
  104. }
  105. };
  106. this.eventBus.on('updatetextlayermatches',
  107. this._onUpdateTextLayerMatches);
  108. }
  109. }
  110. /**
  111. * Cancel rendering of the text layer.
  112. */
  113. cancel() {
  114. if (this.textLayerRenderTask) {
  115. this.textLayerRenderTask.cancel();
  116. this.textLayerRenderTask = null;
  117. }
  118. if (this._onUpdateTextLayerMatches) {
  119. this.eventBus.off('updatetextlayermatches',
  120. this._onUpdateTextLayerMatches);
  121. this._onUpdateTextLayerMatches = null;
  122. }
  123. }
  124. setTextContentStream(readableStream) {
  125. this.cancel();
  126. this.textContentStream = readableStream;
  127. }
  128. setTextContent(textContent) {
  129. this.cancel();
  130. this.textContent = textContent;
  131. }
  132. _convertMatches(matches, matchesLength) {
  133. // Early exit if there is nothing to convert.
  134. if (!matches) {
  135. return [];
  136. }
  137. const { findController, textContentItemsStr, } = this;
  138. let i = 0, iIndex = 0;
  139. const end = textContentItemsStr.length - 1;
  140. const queryLen = findController.state.query.length;
  141. const result = [];
  142. for (let m = 0, mm = matches.length; m < mm; m++) {
  143. // Calculate the start position.
  144. let matchIdx = matches[m];
  145. // Loop over the divIdxs.
  146. while (i !== end &&
  147. matchIdx >= (iIndex + textContentItemsStr[i].length)) {
  148. iIndex += textContentItemsStr[i].length;
  149. i++;
  150. }
  151. if (i === textContentItemsStr.length) {
  152. console.error('Could not find a matching mapping');
  153. }
  154. let match = {
  155. begin: {
  156. divIdx: i,
  157. offset: matchIdx - iIndex,
  158. },
  159. };
  160. // Calculate the end position.
  161. if (matchesLength) { // Multiterm search.
  162. matchIdx += matchesLength[m];
  163. } else { // Phrase search.
  164. matchIdx += queryLen;
  165. }
  166. // Somewhat the same array as above, but use > instead of >= to get
  167. // the end position right.
  168. while (i !== end &&
  169. matchIdx > (iIndex + textContentItemsStr[i].length)) {
  170. iIndex += textContentItemsStr[i].length;
  171. i++;
  172. }
  173. match.end = {
  174. divIdx: i,
  175. offset: matchIdx - iIndex,
  176. };
  177. result.push(match);
  178. }
  179. return result;
  180. }
  181. _renderMatches(matches) {
  182. // Early exit if there is nothing to render.
  183. if (matches.length === 0) {
  184. return;
  185. }
  186. const { findController, pageIdx, textContentItemsStr, textDivs, } = this;
  187. const isSelectedPage = (pageIdx === findController.selected.pageIdx);
  188. const selectedMatchIdx = findController.selected.matchIdx;
  189. const highlightAll = findController.state.highlightAll;
  190. let prevEnd = null;
  191. let infinity = {
  192. divIdx: -1,
  193. offset: undefined,
  194. };
  195. function beginText(begin, className) {
  196. let divIdx = begin.divIdx;
  197. textDivs[divIdx].textContent = '';
  198. appendTextToDiv(divIdx, 0, begin.offset, className);
  199. }
  200. function appendTextToDiv(divIdx, fromOffset, toOffset, className) {
  201. let div = textDivs[divIdx];
  202. let content = textContentItemsStr[divIdx].substring(fromOffset, toOffset);
  203. let node = document.createTextNode(content);
  204. if (className) {
  205. let span = document.createElement('span');
  206. span.className = className;
  207. span.appendChild(node);
  208. div.appendChild(span);
  209. return;
  210. }
  211. div.appendChild(node);
  212. }
  213. let i0 = selectedMatchIdx, i1 = i0 + 1;
  214. if (highlightAll) {
  215. i0 = 0;
  216. i1 = matches.length;
  217. } else if (!isSelectedPage) {
  218. // Not highlighting all and this isn't the selected page, so do nothing.
  219. return;
  220. }
  221. for (let i = i0; i < i1; i++) {
  222. let match = matches[i];
  223. let begin = match.begin;
  224. let end = match.end;
  225. const isSelected = (isSelectedPage && i === selectedMatchIdx);
  226. const highlightSuffix = (isSelected ? ' selected' : '');
  227. if (isSelected) { // Attempt to scroll the selected match into view.
  228. findController.scrollMatchIntoView({
  229. element: textDivs[begin.divIdx],
  230. pageIndex: pageIdx,
  231. matchIndex: selectedMatchIdx,
  232. });
  233. }
  234. // Match inside new div.
  235. if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
  236. // If there was a previous div, then add the text at the end.
  237. if (prevEnd !== null) {
  238. appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
  239. }
  240. // Clear the divs and set the content until the starting point.
  241. beginText(begin);
  242. } else {
  243. appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);
  244. }
  245. if (begin.divIdx === end.divIdx) {
  246. appendTextToDiv(begin.divIdx, begin.offset, end.offset,
  247. 'highlight' + highlightSuffix);
  248. } else {
  249. appendTextToDiv(begin.divIdx, begin.offset, infinity.offset,
  250. 'highlight begin' + highlightSuffix);
  251. for (let n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {
  252. textDivs[n0].className = 'highlight middle' + highlightSuffix;
  253. }
  254. beginText(end, 'highlight end' + highlightSuffix);
  255. }
  256. prevEnd = end;
  257. }
  258. if (prevEnd) {
  259. appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
  260. }
  261. }
  262. _updateMatches() {
  263. // Only show matches when all rendering is done.
  264. if (!this.renderingDone) {
  265. return;
  266. }
  267. const {
  268. findController, matches, pageIdx, textContentItemsStr, textDivs,
  269. } = this;
  270. let clearedUntilDivIdx = -1;
  271. // Clear all current matches.
  272. for (let i = 0, ii = matches.length; i < ii; i++) {
  273. let match = matches[i];
  274. let begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
  275. for (let n = begin, end = match.end.divIdx; n <= end; n++) {
  276. let div = textDivs[n];
  277. div.textContent = textContentItemsStr[n];
  278. div.className = '';
  279. }
  280. clearedUntilDivIdx = match.end.divIdx + 1;
  281. }
  282. if (!findController || !findController.highlightMatches) {
  283. return;
  284. }
  285. // Convert the matches on the `findController` into the match format
  286. // used for the textLayer.
  287. const pageMatches = findController.pageMatches[pageIdx] || null;
  288. const pageMatchesLength = findController.pageMatchesLength[pageIdx] || null;
  289. this.matches = this._convertMatches(pageMatches, pageMatchesLength);
  290. this._renderMatches(this.matches);
  291. }
  292. /**
  293. * Improves text selection by adding an additional div where the mouse was
  294. * clicked. This reduces flickering of the content if the mouse is slowly
  295. * dragged up or down.
  296. *
  297. * @private
  298. */
  299. _bindMouse() {
  300. let div = this.textLayerDiv;
  301. let expandDivsTimer = null;
  302. div.addEventListener('mousedown', (evt) => {
  303. if (this.enhanceTextSelection && this.textLayerRenderTask) {
  304. this.textLayerRenderTask.expandTextDivs(true);
  305. if ((typeof PDFJSDev === 'undefined' ||
  306. !PDFJSDev.test('FIREFOX || MOZCENTRAL')) &&
  307. expandDivsTimer) {
  308. clearTimeout(expandDivsTimer);
  309. expandDivsTimer = null;
  310. }
  311. return;
  312. }
  313. let end = div.querySelector('.endOfContent');
  314. if (!end) {
  315. return;
  316. }
  317. if (typeof PDFJSDev === 'undefined' ||
  318. !PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
  319. // On non-Firefox browsers, the selection will feel better if the height
  320. // of the `endOfContent` div is adjusted to start at mouse click
  321. // location. This avoids flickering when the selection moves up.
  322. // However it does not work when selection is started on empty space.
  323. let adjustTop = evt.target !== div;
  324. if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('GENERIC')) {
  325. adjustTop = adjustTop && window.getComputedStyle(end).
  326. getPropertyValue('-moz-user-select') !== 'none';
  327. }
  328. if (adjustTop) {
  329. let divBounds = div.getBoundingClientRect();
  330. let r = Math.max(0, (evt.pageY - divBounds.top) / divBounds.height);
  331. end.style.top = (r * 100).toFixed(2) + '%';
  332. }
  333. }
  334. end.classList.add('active');
  335. });
  336. div.addEventListener('mouseup', () => {
  337. if (this.enhanceTextSelection && this.textLayerRenderTask) {
  338. if (typeof PDFJSDev === 'undefined' ||
  339. !PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
  340. expandDivsTimer = setTimeout(() => {
  341. if (this.textLayerRenderTask) {
  342. this.textLayerRenderTask.expandTextDivs(false);
  343. }
  344. expandDivsTimer = null;
  345. }, EXPAND_DIVS_TIMEOUT);
  346. } else {
  347. this.textLayerRenderTask.expandTextDivs(false);
  348. }
  349. return;
  350. }
  351. let end = div.querySelector('.endOfContent');
  352. if (!end) {
  353. return;
  354. }
  355. if (typeof PDFJSDev === 'undefined' ||
  356. !PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
  357. end.style.top = '';
  358. }
  359. end.classList.remove('active');
  360. });
  361. }
  362. }
  363. /**
  364. * @implements IPDFTextLayerFactory
  365. */
  366. class DefaultTextLayerFactory {
  367. /**
  368. * @param {HTMLDivElement} textLayerDiv
  369. * @param {number} pageIndex
  370. * @param {PageViewport} viewport
  371. * @param {boolean} enhanceTextSelection
  372. * @returns {TextLayerBuilder}
  373. */
  374. createTextLayerBuilder(textLayerDiv, pageIndex, viewport,
  375. enhanceTextSelection = false) {
  376. return new TextLayerBuilder({
  377. textLayerDiv,
  378. pageIndex,
  379. viewport,
  380. enhanceTextSelection,
  381. });
  382. }
  383. }
  384. export {
  385. TextLayerBuilder,
  386. DefaultTextLayerFactory,
  387. };