text_layer_builder.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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, PDFFindController, scrollIntoView */
  17. 'use strict';
  18. var FIND_SCROLL_OFFSET_TOP = -50;
  19. var FIND_SCROLL_OFFSET_LEFT = -400;
  20. /**
  21. * TextLayerBuilder provides text-selection
  22. * functionality for the PDF. It does this
  23. * by creating overlay divs over the PDF
  24. * text. This divs contain text that matches
  25. * the PDF text they are overlaying. This
  26. * object also provides for a way to highlight
  27. * text that is being searched for.
  28. */
  29. var TextLayerBuilder = function textLayerBuilder(options) {
  30. var textLayerFrag = document.createDocumentFragment();
  31. this.textLayerDiv = options.textLayerDiv;
  32. this.layoutDone = false;
  33. this.divContentDone = false;
  34. this.pageIdx = options.pageIndex;
  35. this.matches = [];
  36. this.lastScrollSource = options.lastScrollSource;
  37. this.viewport = options.viewport;
  38. this.isViewerInPresentationMode = options.isViewerInPresentationMode;
  39. if(typeof PDFFindController === 'undefined') {
  40. window.PDFFindController = null;
  41. }
  42. if(typeof this.lastScrollSource === 'undefined') {
  43. this.lastScrollSource = null;
  44. }
  45. this.beginLayout = function textLayerBuilderBeginLayout() {
  46. this.textDivs = [];
  47. this.renderingDone = false;
  48. };
  49. this.endLayout = function textLayerBuilderEndLayout() {
  50. this.layoutDone = true;
  51. this.insertDivContent();
  52. };
  53. this.renderLayer = function textLayerBuilderRenderLayer() {
  54. var self = this;
  55. var textDivs = this.textDivs;
  56. var bidiTexts = this.textContent;
  57. var textLayerDiv = this.textLayerDiv;
  58. var canvas = document.createElement('canvas');
  59. var ctx = canvas.getContext('2d');
  60. // No point in rendering so many divs as it'd make the browser unusable
  61. // even after the divs are rendered
  62. var MAX_TEXT_DIVS_TO_RENDER = 100000;
  63. if (textDivs.length > MAX_TEXT_DIVS_TO_RENDER)
  64. return;
  65. for (var i = 0, ii = textDivs.length; i < ii; i++) {
  66. var textDiv = textDivs[i];
  67. if ('isWhitespace' in textDiv.dataset) {
  68. continue;
  69. }
  70. ctx.font = textDiv.style.fontSize + ' ' + textDiv.style.fontFamily;
  71. var width = ctx.measureText(textDiv.textContent).width;
  72. if (width > 0) {
  73. textLayerFrag.appendChild(textDiv);
  74. var textScale = textDiv.dataset.canvasWidth / width;
  75. var rotation = textDiv.dataset.angle;
  76. var transform = 'scale(' + textScale + ', 1)';
  77. transform = 'rotate(' + rotation + 'deg) ' + transform;
  78. CustomStyle.setProp('transform' , textDiv, transform);
  79. CustomStyle.setProp('transformOrigin' , textDiv, '0% 0%');
  80. }
  81. }
  82. textLayerDiv.appendChild(textLayerFrag);
  83. this.renderingDone = true;
  84. this.updateMatches();
  85. };
  86. this.setupRenderLayoutTimer = function textLayerSetupRenderLayoutTimer() {
  87. // Schedule renderLayout() if user has been scrolling, otherwise
  88. // run it right away
  89. var RENDER_DELAY = 200; // in ms
  90. var self = this;
  91. var lastScroll = this.lastScrollSource === null ?
  92. 0 : this.lastScrollSource.lastScroll;
  93. if (Date.now() - lastScroll > RENDER_DELAY) {
  94. // Render right away
  95. this.renderLayer();
  96. } else {
  97. // Schedule
  98. if (this.renderTimer)
  99. clearTimeout(this.renderTimer);
  100. this.renderTimer = setTimeout(function() {
  101. self.setupRenderLayoutTimer();
  102. }, RENDER_DELAY);
  103. }
  104. };
  105. this.appendText = function textLayerBuilderAppendText(geom) {
  106. var textDiv = document.createElement('div');
  107. // vScale and hScale already contain the scaling to pixel units
  108. var fontHeight = geom.fontSize * Math.abs(geom.vScale);
  109. textDiv.dataset.canvasWidth = geom.canvasWidth * Math.abs(geom.hScale);
  110. textDiv.dataset.fontName = geom.fontName;
  111. textDiv.dataset.angle = geom.angle * (180 / Math.PI);
  112. textDiv.style.fontSize = fontHeight + 'px';
  113. textDiv.style.fontFamily = geom.fontFamily;
  114. var fontAscent = geom.ascent ? geom.ascent * fontHeight :
  115. geom.descent ? (1 + geom.descent) * fontHeight : fontHeight;
  116. textDiv.style.left = (geom.x + (fontAscent * Math.sin(geom.angle))) + 'px';
  117. textDiv.style.top = (geom.y - (fontAscent * Math.cos(geom.angle))) + 'px';
  118. // The content of the div is set in the `setTextContent` function.
  119. this.textDivs.push(textDiv);
  120. };
  121. this.insertDivContent = function textLayerUpdateTextContent() {
  122. // Only set the content of the divs once layout has finished, the content
  123. // for the divs is available and content is not yet set on the divs.
  124. if (!this.layoutDone || this.divContentDone || !this.textContent)
  125. return;
  126. this.divContentDone = true;
  127. var textDivs = this.textDivs;
  128. var bidiTexts = this.textContent;
  129. for (var i = 0; i < bidiTexts.length; i++) {
  130. var bidiText = bidiTexts[i];
  131. var textDiv = textDivs[i];
  132. if (!/\S/.test(bidiText.str)) {
  133. textDiv.dataset.isWhitespace = true;
  134. continue;
  135. }
  136. textDiv.textContent = bidiText.str;
  137. // TODO refactor text layer to use text content position
  138. /**
  139. * var arr = this.viewport.convertToViewportPoint(bidiText.x, bidiText.y);
  140. * textDiv.style.left = arr[0] + 'px';
  141. * textDiv.style.top = arr[1] + 'px';
  142. */
  143. // bidiText.dir may be 'ttb' for vertical texts.
  144. textDiv.dir = bidiText.dir;
  145. }
  146. this.setupRenderLayoutTimer();
  147. };
  148. this.setTextContent = function textLayerBuilderSetTextContent(textContent) {
  149. this.textContent = textContent;
  150. this.insertDivContent();
  151. };
  152. this.convertMatches = function textLayerBuilderConvertMatches(matches) {
  153. var i = 0;
  154. var iIndex = 0;
  155. var bidiTexts = this.textContent;
  156. var end = bidiTexts.length - 1;
  157. var queryLen = PDFFindController === null ?
  158. 0 : PDFFindController.state.query.length;
  159. var lastDivIdx = -1;
  160. var pos;
  161. var ret = [];
  162. // Loop over all the matches.
  163. for (var m = 0; m < matches.length; m++) {
  164. var matchIdx = matches[m];
  165. // # Calculate the begin position.
  166. // Loop over the divIdxs.
  167. while (i !== end && matchIdx >= (iIndex + bidiTexts[i].str.length)) {
  168. iIndex += bidiTexts[i].str.length;
  169. i++;
  170. }
  171. // TODO: Do proper handling here if something goes wrong.
  172. if (i == bidiTexts.length) {
  173. console.error('Could not find matching mapping');
  174. }
  175. var match = {
  176. begin: {
  177. divIdx: i,
  178. offset: matchIdx - iIndex
  179. }
  180. };
  181. // # Calculate the end position.
  182. matchIdx += queryLen;
  183. // Somewhat same array as above, but use a > instead of >= to get the end
  184. // position right.
  185. while (i !== end && matchIdx > (iIndex + bidiTexts[i].str.length)) {
  186. iIndex += bidiTexts[i].str.length;
  187. i++;
  188. }
  189. match.end = {
  190. divIdx: i,
  191. offset: matchIdx - iIndex
  192. };
  193. ret.push(match);
  194. }
  195. return ret;
  196. };
  197. this.renderMatches = function textLayerBuilder_renderMatches(matches) {
  198. // Early exit if there is nothing to render.
  199. if (matches.length === 0) {
  200. return;
  201. }
  202. var bidiTexts = this.textContent;
  203. var textDivs = this.textDivs;
  204. var prevEnd = null;
  205. var isSelectedPage = PDFFindController === null ?
  206. false : (this.pageIdx === PDFFindController.selected.pageIdx);
  207. var selectedMatchIdx = PDFFindController === null ?
  208. -1 : PDFFindController.selected.matchIdx;
  209. var highlightAll = PDFFindController === null ?
  210. false : PDFFindController.state.highlightAll;
  211. var infty = {
  212. divIdx: -1,
  213. offset: undefined
  214. };
  215. function beginText(begin, className) {
  216. var divIdx = begin.divIdx;
  217. var div = textDivs[divIdx];
  218. div.textContent = '';
  219. var content = bidiTexts[divIdx].str.substring(0, begin.offset);
  220. var node = document.createTextNode(content);
  221. if (className) {
  222. var isSelected = isSelectedPage &&
  223. divIdx === selectedMatchIdx;
  224. var span = document.createElement('span');
  225. span.className = className + (isSelected ? ' selected' : '');
  226. span.appendChild(node);
  227. div.appendChild(span);
  228. return;
  229. }
  230. div.appendChild(node);
  231. }
  232. function appendText(from, to, className) {
  233. var divIdx = from.divIdx;
  234. var div = textDivs[divIdx];
  235. var content = bidiTexts[divIdx].str.substring(from.offset, to.offset);
  236. var node = document.createTextNode(content);
  237. if (className) {
  238. var span = document.createElement('span');
  239. span.className = className;
  240. span.appendChild(node);
  241. div.appendChild(span);
  242. return;
  243. }
  244. div.appendChild(node);
  245. }
  246. function highlightDiv(divIdx, className) {
  247. textDivs[divIdx].className = className;
  248. }
  249. var i0 = selectedMatchIdx, i1 = i0 + 1, i;
  250. if (highlightAll) {
  251. i0 = 0;
  252. i1 = matches.length;
  253. } else if (!isSelectedPage) {
  254. // Not highlighting all and this isn't the selected page, so do nothing.
  255. return;
  256. }
  257. for (i = i0; i < i1; i++) {
  258. var match = matches[i];
  259. var begin = match.begin;
  260. var end = match.end;
  261. var isSelected = isSelectedPage && i === selectedMatchIdx;
  262. var highlightSuffix = (isSelected ? ' selected' : '');
  263. if (isSelected && !this.isViewerInPresentationMode) {
  264. scrollIntoView(textDivs[begin.divIdx], { top: FIND_SCROLL_OFFSET_TOP,
  265. left: FIND_SCROLL_OFFSET_LEFT });
  266. }
  267. // Match inside new div.
  268. if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
  269. // If there was a previous div, then add the text at the end
  270. if (prevEnd !== null) {
  271. appendText(prevEnd, infty);
  272. }
  273. // clears the divs and set the content until the begin point.
  274. beginText(begin);
  275. } else {
  276. appendText(prevEnd, begin);
  277. }
  278. if (begin.divIdx === end.divIdx) {
  279. appendText(begin, end, 'highlight' + highlightSuffix);
  280. } else {
  281. appendText(begin, infty, 'highlight begin' + highlightSuffix);
  282. for (var n = begin.divIdx + 1; n < end.divIdx; n++) {
  283. highlightDiv(n, 'highlight middle' + highlightSuffix);
  284. }
  285. beginText(end, 'highlight end' + highlightSuffix);
  286. }
  287. prevEnd = end;
  288. }
  289. if (prevEnd) {
  290. appendText(prevEnd, infty);
  291. }
  292. };
  293. this.updateMatches = function textLayerUpdateMatches() {
  294. // Only show matches, once all rendering is done.
  295. if (!this.renderingDone)
  296. return;
  297. // Clear out all matches.
  298. var matches = this.matches;
  299. var textDivs = this.textDivs;
  300. var bidiTexts = this.textContent;
  301. var clearedUntilDivIdx = -1;
  302. // Clear out all current matches.
  303. for (var i = 0; i < matches.length; i++) {
  304. var match = matches[i];
  305. var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
  306. for (var n = begin; n <= match.end.divIdx; n++) {
  307. var div = textDivs[n];
  308. div.textContent = bidiTexts[n].str;
  309. div.className = '';
  310. }
  311. clearedUntilDivIdx = match.end.divIdx + 1;
  312. }
  313. if (PDFFindController === null || !PDFFindController.active)
  314. return;
  315. // Convert the matches on the page controller into the match format used
  316. // for the textLayer.
  317. this.matches = matches =
  318. this.convertMatches(PDFFindController === null ?
  319. [] : (PDFFindController.pageMatches[this.pageIdx] || []));
  320. this.renderMatches(this.matches);
  321. };
  322. };