You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

419 lines
14 KiB

  1. /* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
  2. /* Copyright 2012 Mozilla Foundation
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License");
  5. * you may not use this file except in compliance with the License.
  6. * You may obtain a copy of the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS,
  12. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. * See the License for the specific language governing permissions and
  14. * limitations under the License.
  15. */
  16. /* globals CustomStyle, PDFJS */
  17. 'use strict';
  18. var MAX_TEXT_DIVS_TO_RENDER = 100000;
  19. var NonWhitespaceRegexp = /\S/;
  20. function isAllWhitespace(str) {
  21. return !NonWhitespaceRegexp.test(str);
  22. }
  23. /**
  24. * @typedef {Object} TextLayerBuilderOptions
  25. * @property {HTMLDivElement} textLayerDiv - The text layer container.
  26. * @property {number} pageIndex - The page index.
  27. * @property {PageViewport} viewport - The viewport of the text layer.
  28. * @property {PDFFindController} findController
  29. */
  30. /**
  31. * TextLayerBuilder provides text-selection functionality for the PDF.
  32. * It does this by creating overlay divs over the PDF text. These divs
  33. * contain text that matches the PDF text they are overlaying. This object
  34. * also provides a way to highlight text that is being searched for.
  35. * @class
  36. */
  37. var TextLayerBuilder = (function TextLayerBuilderClosure() {
  38. function TextLayerBuilder(options) {
  39. this.textLayerDiv = options.textLayerDiv;
  40. this.renderingDone = false;
  41. this.divContentDone = false;
  42. this.pageIdx = options.pageIndex;
  43. this.pageNumber = this.pageIdx + 1;
  44. this.matches = [];
  45. this.viewport = options.viewport;
  46. this.textDivs = [];
  47. this.findController = options.findController || null;
  48. }
  49. TextLayerBuilder.prototype = {
  50. _finishRendering: function TextLayerBuilder_finishRendering() {
  51. this.renderingDone = true;
  52. var event = document.createEvent('CustomEvent');
  53. event.initCustomEvent('textlayerrendered', true, true, {
  54. pageNumber: this.pageNumber
  55. });
  56. this.textLayerDiv.dispatchEvent(event);
  57. },
  58. renderLayer: function TextLayerBuilder_renderLayer() {
  59. var textLayerFrag = document.createDocumentFragment();
  60. var textDivs = this.textDivs;
  61. var textDivsLength = textDivs.length;
  62. var canvas = document.createElement('canvas');
  63. var ctx = canvas.getContext('2d');
  64. // No point in rendering many divs as it would make the browser
  65. // unusable even after the divs are rendered.
  66. if (textDivsLength > MAX_TEXT_DIVS_TO_RENDER) {
  67. this._finishRendering();
  68. return;
  69. }
  70. var lastFontSize;
  71. var lastFontFamily;
  72. for (var i = 0; i < textDivsLength; i++) {
  73. var textDiv = textDivs[i];
  74. if (textDiv.dataset.isWhitespace !== undefined) {
  75. continue;
  76. }
  77. var fontSize = textDiv.style.fontSize;
  78. var fontFamily = textDiv.style.fontFamily;
  79. // Only build font string and set to context if different from last.
  80. if (fontSize !== lastFontSize || fontFamily !== lastFontFamily) {
  81. ctx.font = fontSize + ' ' + fontFamily;
  82. lastFontSize = fontSize;
  83. lastFontFamily = fontFamily;
  84. }
  85. var width = ctx.measureText(textDiv.textContent).width;
  86. if (width > 0) {
  87. textLayerFrag.appendChild(textDiv);
  88. var transform;
  89. if (textDiv.dataset.canvasWidth !== undefined) {
  90. // Dataset values come of type string.
  91. var textScale = textDiv.dataset.canvasWidth / width;
  92. transform = 'scaleX(' + textScale + ')';
  93. } else {
  94. transform = '';
  95. }
  96. var rotation = textDiv.dataset.angle;
  97. if (rotation) {
  98. transform = 'rotate(' + rotation + 'deg) ' + transform;
  99. }
  100. if (transform) {
  101. CustomStyle.setProp('transform' , textDiv, transform);
  102. }
  103. }
  104. }
  105. this.textLayerDiv.appendChild(textLayerFrag);
  106. this._finishRendering();
  107. this.updateMatches();
  108. },
  109. /**
  110. * Renders the text layer.
  111. * @param {number} timeout (optional) if specified, the rendering waits
  112. * for specified amount of ms.
  113. */
  114. render: function TextLayerBuilder_render(timeout) {
  115. if (!this.divContentDone || this.renderingDone) {
  116. return;
  117. }
  118. if (this.renderTimer) {
  119. clearTimeout(this.renderTimer);
  120. this.renderTimer = null;
  121. }
  122. if (!timeout) { // Render right away
  123. this.renderLayer();
  124. } else { // Schedule
  125. var self = this;
  126. this.renderTimer = setTimeout(function() {
  127. self.renderLayer();
  128. self.renderTimer = null;
  129. }, timeout);
  130. }
  131. },
  132. appendText: function TextLayerBuilder_appendText(geom, styles) {
  133. var style = styles[geom.fontName];
  134. var textDiv = document.createElement('div');
  135. this.textDivs.push(textDiv);
  136. if (isAllWhitespace(geom.str)) {
  137. textDiv.dataset.isWhitespace = true;
  138. return;
  139. }
  140. var tx = PDFJS.Util.transform(this.viewport.transform, geom.transform);
  141. var angle = Math.atan2(tx[1], tx[0]);
  142. if (style.vertical) {
  143. angle += Math.PI / 2;
  144. }
  145. var fontHeight = Math.sqrt((tx[2] * tx[2]) + (tx[3] * tx[3]));
  146. var fontAscent = fontHeight;
  147. if (style.ascent) {
  148. fontAscent = style.ascent * fontAscent;
  149. } else if (style.descent) {
  150. fontAscent = (1 + style.descent) * fontAscent;
  151. }
  152. var left;
  153. var top;
  154. if (angle === 0) {
  155. left = tx[4];
  156. top = tx[5] - fontAscent;
  157. } else {
  158. left = tx[4] + (fontAscent * Math.sin(angle));
  159. top = tx[5] - (fontAscent * Math.cos(angle));
  160. }
  161. textDiv.style.left = left + 'px';
  162. textDiv.style.top = top + 'px';
  163. textDiv.style.fontSize = fontHeight + 'px';
  164. textDiv.style.fontFamily = style.fontFamily;
  165. textDiv.textContent = geom.str;
  166. // |fontName| is only used by the Font Inspector. This test will succeed
  167. // when e.g. the Font Inspector is off but the Stepper is on, but it's
  168. // not worth the effort to do a more accurate test.
  169. if (PDFJS.pdfBug) {
  170. textDiv.dataset.fontName = geom.fontName;
  171. }
  172. // Storing into dataset will convert number into string.
  173. if (angle !== 0) {
  174. textDiv.dataset.angle = angle * (180 / Math.PI);
  175. }
  176. // We don't bother scaling single-char text divs, because it has very
  177. // little effect on text highlighting. This makes scrolling on docs with
  178. // lots of such divs a lot faster.
  179. if (textDiv.textContent.length > 1) {
  180. if (style.vertical) {
  181. textDiv.dataset.canvasWidth = geom.height * this.viewport.scale;
  182. } else {
  183. textDiv.dataset.canvasWidth = geom.width * this.viewport.scale;
  184. }
  185. }
  186. },
  187. setTextContent: function TextLayerBuilder_setTextContent(textContent) {
  188. this.textContent = textContent;
  189. var textItems = textContent.items;
  190. for (var i = 0, len = textItems.length; i < len; i++) {
  191. this.appendText(textItems[i], textContent.styles);
  192. }
  193. this.divContentDone = true;
  194. },
  195. convertMatches: function TextLayerBuilder_convertMatches(matches) {
  196. var i = 0;
  197. var iIndex = 0;
  198. var bidiTexts = this.textContent.items;
  199. var end = bidiTexts.length - 1;
  200. var queryLen = (this.findController === null ?
  201. 0 : this.findController.state.query.length);
  202. var ret = [];
  203. for (var m = 0, len = matches.length; m < len; m++) {
  204. // Calculate the start position.
  205. var matchIdx = matches[m];
  206. // Loop over the divIdxs.
  207. while (i !== end && matchIdx >= (iIndex + bidiTexts[i].str.length)) {
  208. iIndex += bidiTexts[i].str.length;
  209. i++;
  210. }
  211. if (i === bidiTexts.length) {
  212. console.error('Could not find a matching mapping');
  213. }
  214. var match = {
  215. begin: {
  216. divIdx: i,
  217. offset: matchIdx - iIndex
  218. }
  219. };
  220. // Calculate the end position.
  221. matchIdx += queryLen;
  222. // Somewhat the same array as above, but use > instead of >= to get
  223. // the end position right.
  224. while (i !== end && matchIdx > (iIndex + bidiTexts[i].str.length)) {
  225. iIndex += bidiTexts[i].str.length;
  226. i++;
  227. }
  228. match.end = {
  229. divIdx: i,
  230. offset: matchIdx - iIndex
  231. };
  232. ret.push(match);
  233. }
  234. return ret;
  235. },
  236. renderMatches: function TextLayerBuilder_renderMatches(matches) {
  237. // Early exit if there is nothing to render.
  238. if (matches.length === 0) {
  239. return;
  240. }
  241. var bidiTexts = this.textContent.items;
  242. var textDivs = this.textDivs;
  243. var prevEnd = null;
  244. var pageIdx = this.pageIdx;
  245. var isSelectedPage = (this.findController === null ?
  246. false : (pageIdx === this.findController.selected.pageIdx));
  247. var selectedMatchIdx = (this.findController === null ?
  248. -1 : this.findController.selected.matchIdx);
  249. var highlightAll = (this.findController === null ?
  250. false : this.findController.state.highlightAll);
  251. var infinity = {
  252. divIdx: -1,
  253. offset: undefined
  254. };
  255. function beginText(begin, className) {
  256. var divIdx = begin.divIdx;
  257. textDivs[divIdx].textContent = '';
  258. appendTextToDiv(divIdx, 0, begin.offset, className);
  259. }
  260. function appendTextToDiv(divIdx, fromOffset, toOffset, className) {
  261. var div = textDivs[divIdx];
  262. var content = bidiTexts[divIdx].str.substring(fromOffset, toOffset);
  263. var node = document.createTextNode(content);
  264. if (className) {
  265. var span = document.createElement('span');
  266. span.className = className;
  267. span.appendChild(node);
  268. div.appendChild(span);
  269. return;
  270. }
  271. div.appendChild(node);
  272. }
  273. var i0 = selectedMatchIdx, i1 = i0 + 1;
  274. if (highlightAll) {
  275. i0 = 0;
  276. i1 = matches.length;
  277. } else if (!isSelectedPage) {
  278. // Not highlighting all and this isn't the selected page, so do nothing.
  279. return;
  280. }
  281. for (var i = i0; i < i1; i++) {
  282. var match = matches[i];
  283. var begin = match.begin;
  284. var end = match.end;
  285. var isSelected = (isSelectedPage && i === selectedMatchIdx);
  286. var highlightSuffix = (isSelected ? ' selected' : '');
  287. if (this.findController) {
  288. this.findController.updateMatchPosition(pageIdx, i, textDivs,
  289. begin.divIdx, end.divIdx);
  290. }
  291. // Match inside new div.
  292. if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
  293. // If there was a previous div, then add the text at the end.
  294. if (prevEnd !== null) {
  295. appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
  296. }
  297. // Clear the divs and set the content until the starting point.
  298. beginText(begin);
  299. } else {
  300. appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);
  301. }
  302. if (begin.divIdx === end.divIdx) {
  303. appendTextToDiv(begin.divIdx, begin.offset, end.offset,
  304. 'highlight' + highlightSuffix);
  305. } else {
  306. appendTextToDiv(begin.divIdx, begin.offset, infinity.offset,
  307. 'highlight begin' + highlightSuffix);
  308. for (var n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {
  309. textDivs[n0].className = 'highlight middle' + highlightSuffix;
  310. }
  311. beginText(end, 'highlight end' + highlightSuffix);
  312. }
  313. prevEnd = end;
  314. }
  315. if (prevEnd) {
  316. appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
  317. }
  318. },
  319. updateMatches: function TextLayerBuilder_updateMatches() {
  320. // Only show matches when all rendering is done.
  321. if (!this.renderingDone) {
  322. return;
  323. }
  324. // Clear all matches.
  325. var matches = this.matches;
  326. var textDivs = this.textDivs;
  327. var bidiTexts = this.textContent.items;
  328. var clearedUntilDivIdx = -1;
  329. // Clear all current matches.
  330. for (var i = 0, len = matches.length; i < len; i++) {
  331. var match = matches[i];
  332. var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
  333. for (var n = begin, end = match.end.divIdx; n <= end; n++) {
  334. var div = textDivs[n];
  335. div.textContent = bidiTexts[n].str;
  336. div.className = '';
  337. }
  338. clearedUntilDivIdx = match.end.divIdx + 1;
  339. }
  340. if (this.findController === null || !this.findController.active) {
  341. return;
  342. }
  343. // Convert the matches on the page controller into the match format
  344. // used for the textLayer.
  345. this.matches = this.convertMatches(this.findController === null ?
  346. [] : (this.findController.pageMatches[this.pageIdx] || []));
  347. this.renderMatches(this.matches);
  348. }
  349. };
  350. return TextLayerBuilder;
  351. })();
  352. /**
  353. * @constructor
  354. * @implements IPDFTextLayerFactory
  355. */
  356. function DefaultTextLayerFactory() {}
  357. DefaultTextLayerFactory.prototype = {
  358. /**
  359. * @param {HTMLDivElement} textLayerDiv
  360. * @param {number} pageIndex
  361. * @param {PageViewport} viewport
  362. * @returns {TextLayerBuilder}
  363. */
  364. createTextLayerBuilder: function (textLayerDiv, pageIndex, viewport) {
  365. return new TextLayerBuilder({
  366. textLayerDiv: textLayerDiv,
  367. pageIndex: pageIndex,
  368. viewport: viewport
  369. });
  370. }
  371. };