Path: blob/master/source/_static/ViewerJS/text_layer_builder.js
1237 views
/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */1/* Copyright 2012 Mozilla Foundation2*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 at6*7* http://www.apache.org/licenses/LICENSE-2.08*9* Unless required by applicable law or agreed to in writing, software10* 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 and13* limitations under the License.14*/15/* globals CustomStyle, PDFJS */1617'use strict';1819var MAX_TEXT_DIVS_TO_RENDER = 100000;2021var NonWhitespaceRegexp = /\S/;2223function isAllWhitespace(str) {24return !NonWhitespaceRegexp.test(str);25}2627/**28* @typedef {Object} TextLayerBuilderOptions29* @property {HTMLDivElement} textLayerDiv - The text layer container.30* @property {number} pageIndex - The page index.31* @property {PageViewport} viewport - The viewport of the text layer.32* @property {PDFFindController} findController33*/3435/**36* TextLayerBuilder provides text-selection functionality for the PDF.37* It does this by creating overlay divs over the PDF text. These divs38* contain text that matches the PDF text they are overlaying. This object39* also provides a way to highlight text that is being searched for.40* @class41*/42var TextLayerBuilder = (function TextLayerBuilderClosure() {43function TextLayerBuilder(options) {44this.textLayerDiv = options.textLayerDiv;45this.renderingDone = false;46this.divContentDone = false;47this.pageIdx = options.pageIndex;48this.pageNumber = this.pageIdx + 1;49this.matches = [];50this.viewport = options.viewport;51this.textDivs = [];52this.findController = options.findController || null;53}5455TextLayerBuilder.prototype = {56_finishRendering: function TextLayerBuilder_finishRendering() {57this.renderingDone = true;5859var event = document.createEvent('CustomEvent');60event.initCustomEvent('textlayerrendered', true, true, {61pageNumber: this.pageNumber62});63this.textLayerDiv.dispatchEvent(event);64},6566renderLayer: function TextLayerBuilder_renderLayer() {67var textLayerFrag = document.createDocumentFragment();68var textDivs = this.textDivs;69var textDivsLength = textDivs.length;70var canvas = document.createElement('canvas');71var ctx = canvas.getContext('2d');7273// No point in rendering many divs as it would make the browser74// unusable even after the divs are rendered.75if (textDivsLength > MAX_TEXT_DIVS_TO_RENDER) {76this._finishRendering();77return;78}7980var lastFontSize;81var lastFontFamily;82for (var i = 0; i < textDivsLength; i++) {83var textDiv = textDivs[i];84if (textDiv.dataset.isWhitespace !== undefined) {85continue;86}8788var fontSize = textDiv.style.fontSize;89var fontFamily = textDiv.style.fontFamily;9091// Only build font string and set to context if different from last.92if (fontSize !== lastFontSize || fontFamily !== lastFontFamily) {93ctx.font = fontSize + ' ' + fontFamily;94lastFontSize = fontSize;95lastFontFamily = fontFamily;96}9798var width = ctx.measureText(textDiv.textContent).width;99if (width > 0) {100textLayerFrag.appendChild(textDiv);101var transform;102if (textDiv.dataset.canvasWidth !== undefined) {103// Dataset values come of type string.104var textScale = textDiv.dataset.canvasWidth / width;105transform = 'scaleX(' + textScale + ')';106} else {107transform = '';108}109var rotation = textDiv.dataset.angle;110if (rotation) {111transform = 'rotate(' + rotation + 'deg) ' + transform;112}113if (transform) {114CustomStyle.setProp('transform' , textDiv, transform);115}116}117}118119this.textLayerDiv.appendChild(textLayerFrag);120this._finishRendering();121this.updateMatches();122},123124/**125* Renders the text layer.126* @param {number} timeout (optional) if specified, the rendering waits127* for specified amount of ms.128*/129render: function TextLayerBuilder_render(timeout) {130if (!this.divContentDone || this.renderingDone) {131return;132}133134if (this.renderTimer) {135clearTimeout(this.renderTimer);136this.renderTimer = null;137}138139if (!timeout) { // Render right away140this.renderLayer();141} else { // Schedule142var self = this;143this.renderTimer = setTimeout(function() {144self.renderLayer();145self.renderTimer = null;146}, timeout);147}148},149150appendText: function TextLayerBuilder_appendText(geom, styles) {151var style = styles[geom.fontName];152var textDiv = document.createElement('div');153this.textDivs.push(textDiv);154if (isAllWhitespace(geom.str)) {155textDiv.dataset.isWhitespace = true;156return;157}158var tx = PDFJS.Util.transform(this.viewport.transform, geom.transform);159var angle = Math.atan2(tx[1], tx[0]);160if (style.vertical) {161angle += Math.PI / 2;162}163var fontHeight = Math.sqrt((tx[2] * tx[2]) + (tx[3] * tx[3]));164var fontAscent = fontHeight;165if (style.ascent) {166fontAscent = style.ascent * fontAscent;167} else if (style.descent) {168fontAscent = (1 + style.descent) * fontAscent;169}170171var left;172var top;173if (angle === 0) {174left = tx[4];175top = tx[5] - fontAscent;176} else {177left = tx[4] + (fontAscent * Math.sin(angle));178top = tx[5] - (fontAscent * Math.cos(angle));179}180textDiv.style.left = left + 'px';181textDiv.style.top = top + 'px';182textDiv.style.fontSize = fontHeight + 'px';183textDiv.style.fontFamily = style.fontFamily;184185textDiv.textContent = geom.str;186// |fontName| is only used by the Font Inspector. This test will succeed187// when e.g. the Font Inspector is off but the Stepper is on, but it's188// not worth the effort to do a more accurate test.189if (PDFJS.pdfBug) {190textDiv.dataset.fontName = geom.fontName;191}192// Storing into dataset will convert number into string.193if (angle !== 0) {194textDiv.dataset.angle = angle * (180 / Math.PI);195}196// We don't bother scaling single-char text divs, because it has very197// little effect on text highlighting. This makes scrolling on docs with198// lots of such divs a lot faster.199if (textDiv.textContent.length > 1) {200if (style.vertical) {201textDiv.dataset.canvasWidth = geom.height * this.viewport.scale;202} else {203textDiv.dataset.canvasWidth = geom.width * this.viewport.scale;204}205}206},207208setTextContent: function TextLayerBuilder_setTextContent(textContent) {209this.textContent = textContent;210211var textItems = textContent.items;212for (var i = 0, len = textItems.length; i < len; i++) {213this.appendText(textItems[i], textContent.styles);214}215this.divContentDone = true;216},217218convertMatches: function TextLayerBuilder_convertMatches(matches) {219var i = 0;220var iIndex = 0;221var bidiTexts = this.textContent.items;222var end = bidiTexts.length - 1;223var queryLen = (this.findController === null ?2240 : this.findController.state.query.length);225var ret = [];226227for (var m = 0, len = matches.length; m < len; m++) {228// Calculate the start position.229var matchIdx = matches[m];230231// Loop over the divIdxs.232while (i !== end && matchIdx >= (iIndex + bidiTexts[i].str.length)) {233iIndex += bidiTexts[i].str.length;234i++;235}236237if (i === bidiTexts.length) {238console.error('Could not find a matching mapping');239}240241var match = {242begin: {243divIdx: i,244offset: matchIdx - iIndex245}246};247248// Calculate the end position.249matchIdx += queryLen;250251// Somewhat the same array as above, but use > instead of >= to get252// the end position right.253while (i !== end && matchIdx > (iIndex + bidiTexts[i].str.length)) {254iIndex += bidiTexts[i].str.length;255i++;256}257258match.end = {259divIdx: i,260offset: matchIdx - iIndex261};262ret.push(match);263}264265return ret;266},267268renderMatches: function TextLayerBuilder_renderMatches(matches) {269// Early exit if there is nothing to render.270if (matches.length === 0) {271return;272}273274var bidiTexts = this.textContent.items;275var textDivs = this.textDivs;276var prevEnd = null;277var pageIdx = this.pageIdx;278var isSelectedPage = (this.findController === null ?279false : (pageIdx === this.findController.selected.pageIdx));280var selectedMatchIdx = (this.findController === null ?281-1 : this.findController.selected.matchIdx);282var highlightAll = (this.findController === null ?283false : this.findController.state.highlightAll);284var infinity = {285divIdx: -1,286offset: undefined287};288289function beginText(begin, className) {290var divIdx = begin.divIdx;291textDivs[divIdx].textContent = '';292appendTextToDiv(divIdx, 0, begin.offset, className);293}294295function appendTextToDiv(divIdx, fromOffset, toOffset, className) {296var div = textDivs[divIdx];297var content = bidiTexts[divIdx].str.substring(fromOffset, toOffset);298var node = document.createTextNode(content);299if (className) {300var span = document.createElement('span');301span.className = className;302span.appendChild(node);303div.appendChild(span);304return;305}306div.appendChild(node);307}308309var i0 = selectedMatchIdx, i1 = i0 + 1;310if (highlightAll) {311i0 = 0;312i1 = matches.length;313} else if (!isSelectedPage) {314// Not highlighting all and this isn't the selected page, so do nothing.315return;316}317318for (var i = i0; i < i1; i++) {319var match = matches[i];320var begin = match.begin;321var end = match.end;322var isSelected = (isSelectedPage && i === selectedMatchIdx);323var highlightSuffix = (isSelected ? ' selected' : '');324325if (this.findController) {326this.findController.updateMatchPosition(pageIdx, i, textDivs,327begin.divIdx, end.divIdx);328}329330// Match inside new div.331if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {332// If there was a previous div, then add the text at the end.333if (prevEnd !== null) {334appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);335}336// Clear the divs and set the content until the starting point.337beginText(begin);338} else {339appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);340}341342if (begin.divIdx === end.divIdx) {343appendTextToDiv(begin.divIdx, begin.offset, end.offset,344'highlight' + highlightSuffix);345} else {346appendTextToDiv(begin.divIdx, begin.offset, infinity.offset,347'highlight begin' + highlightSuffix);348for (var n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {349textDivs[n0].className = 'highlight middle' + highlightSuffix;350}351beginText(end, 'highlight end' + highlightSuffix);352}353prevEnd = end;354}355356if (prevEnd) {357appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);358}359},360361updateMatches: function TextLayerBuilder_updateMatches() {362// Only show matches when all rendering is done.363if (!this.renderingDone) {364return;365}366367// Clear all matches.368var matches = this.matches;369var textDivs = this.textDivs;370var bidiTexts = this.textContent.items;371var clearedUntilDivIdx = -1;372373// Clear all current matches.374for (var i = 0, len = matches.length; i < len; i++) {375var match = matches[i];376var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);377for (var n = begin, end = match.end.divIdx; n <= end; n++) {378var div = textDivs[n];379div.textContent = bidiTexts[n].str;380div.className = '';381}382clearedUntilDivIdx = match.end.divIdx + 1;383}384385if (this.findController === null || !this.findController.active) {386return;387}388389// Convert the matches on the page controller into the match format390// used for the textLayer.391this.matches = this.convertMatches(this.findController === null ?392[] : (this.findController.pageMatches[this.pageIdx] || []));393this.renderMatches(this.matches);394}395};396return TextLayerBuilder;397})();398399/**400* @constructor401* @implements IPDFTextLayerFactory402*/403function DefaultTextLayerFactory() {}404DefaultTextLayerFactory.prototype = {405/**406* @param {HTMLDivElement} textLayerDiv407* @param {number} pageIndex408* @param {PageViewport} viewport409* @returns {TextLayerBuilder}410*/411createTextLayerBuilder: function (textLayerDiv, pageIndex, viewport) {412return new TextLayerBuilder({413textLayerDiv: textLayerDiv,414pageIndex: pageIndex,415viewport: viewport416});417}418};419420421