diff --git a/src/display/text_layer.js b/src/display/text_layer.js index 72e31d69fa4f5d..043bac0694b36c 100644 --- a/src/display/text_layer.js +++ b/src/display/text_layer.js @@ -25,7 +25,7 @@ factory((root.pdfjsDisplayTextLayer = {}), root.pdfjsSharedUtil, root.pdfjsDisplayDOMUtils); } -}(this, function (exports, sharedUtil, displayDOMUtils) { +}(this, function (exports, sharedUtil, displayDOMUtils, pdfjsLib) { var Util = sharedUtil.Util; var createPromiseCapability = sharedUtil.createPromiseCapability; @@ -56,7 +56,12 @@ var renderTextLayer = (function renderTextLayerClosure() { return !NonWhitespaceRegexp.test(str); } - function appendText(textDivs, viewport, geom, styles) { + function appendText(textDivs, + viewport, + geom, + styles, + bounds, + enhanceTextSelection) { var style = styles[geom.fontName]; var textDiv = document.createElement('div'); textDivs.push(textDiv); @@ -112,6 +117,36 @@ var renderTextLayer = (function renderTextLayerClosure() { textDiv.dataset.canvasWidth = geom.width * viewport.scale; } } + if (enhanceTextSelection) { + var angleCos = 1, angleSin = 0; + if (angle !== 0) { + angleCos = Math.cos(angle); + angleSin = Math.sin(angle); + } + var divWidth = (style.vertical ? geom.height : geom.width) * + viewport.scale; + var divHeight = fontHeight; + + var m, b; + if (angle !== 0) { + m = [angleCos, angleSin, -angleSin, angleCos, left, top]; + b = + pdfjsLib.PDFJS.Util. + getAxialAlignedBoundingBox([0, 0, divWidth, divHeight], m); + } else { + b = [left, top, left + divWidth, top + divHeight]; + } + + bounds.push({ + left: b[0], + top: b[1], + right: b[2], + bottom: b[3], + div: textDiv, + size: [divWidth, divHeight], + m: m + }); + } } function render(task) { @@ -168,6 +203,23 @@ var renderTextLayer = (function renderTextLayerClosure() { if (rotation) { transform = 'rotate(' + rotation + 'deg) ' + transform; } + if (textDiv.dataset.paddingLeft) { + textDiv.style.paddingLeft = + (textDiv.dataset.paddingLeft / textScale) + 'px'; + transform += ' translateX(' + + (-textDiv.dataset.paddingLeft / textScale) + 'px)'; + } + if (textDiv.dataset.paddingTop) { + textDiv.style.paddingTop = textDiv.dataset.paddingTop + 'px'; + transform += ' translateY(' + (-textDiv.dataset.paddingTop) + 'px)'; + } + if (textDiv.dataset.paddingRight) { + textDiv.style.paddingRight = + textDiv.dataset.paddingRight / textScale + 'px'; + } + if (textDiv.dataset.paddingBottom) { + textDiv.style.paddingBottom = textDiv.dataset.paddingBottom + 'px'; + } if (transform) { CustomStyle.setProp('transform' , textDiv, transform); } @@ -175,6 +227,271 @@ var renderTextLayer = (function renderTextLayerClosure() { capability.resolve(); } + function expand(bounds, viewport) { + var expanded = expandBounds(viewport.width, viewport.height, + bounds); + for (var i = 0; i < expanded.length; i++) { + var div = bounds[i].div; + if (!div.dataset.angle) { + div.dataset.paddingLeft = bounds[i].left - expanded[i].left; + div.dataset.paddingTop = bounds[i].top - expanded[i].top; + div.dataset.paddingRight = expanded[i].right - bounds[i].right; + div.dataset.paddingBottom = expanded[i].bottom - bounds[i].bottom; + continue; + } + // Box is rotated -- trying to find padding so rotated div will not + // exceed its expanded bounds. + var e = expanded[i], b = bounds[i]; + var m = b.m, c = m[0], s = m[1]; + // Finding intersections with expanded box. + var points = [[0, 0], [0, b.size[1]], [b.size[0], 0], b.size]; + var ts = new Float64Array(64); + points.forEach(function (p, i) { + var t = pdfjsLib.PDFJS.Util.applyTransform(p, m); + ts[i + 0] = c && (e.left - t[0]) / c; + ts[i + 4] = s && (e.top - t[1]) / s; + ts[i + 8] = c && (e.right - t[0]) / c; + ts[i + 12] = s && (e.bottom - t[1]) / s; + + ts[i + 16] = s && (e.left - t[0]) / -s; + ts[i + 20] = c && (e.top - t[1]) / c; + ts[i + 24] = s && (e.right - t[0]) / -s; + ts[i + 28] = c && (e.bottom - t[1]) / c; + + ts[i + 32] = c && (e.left - t[0]) / -c; + ts[i + 36] = s && (e.top - t[1]) / -s; + ts[i + 40] = c && (e.right - t[0]) / -c; + ts[i + 44] = s && (e.bottom - t[1]) / -s; + + ts[i + 48] = s && (e.left - t[0]) / s; + ts[i + 52] = c && (e.top - t[1]) / -c; + ts[i + 56] = s && (e.right - t[0]) / s; + ts[i + 60] = c && (e.bottom - t[1]) / -c; + }); + var findPositiveMin = function (ts, offset, count) { + var result = 0; + for (var i = 0; i < count; i++) { + var t = ts[offset++]; + if (t > 0) { + result = result ? Math.min(t, result) : t; + } + } + return result; + }; + // Not based on math, but to simplify calculations, using cos and sin + // absolute values to not exceed the box (it can but insignificantly). + var boxScale = 1 + Math.min(Math.abs(c), Math.abs(s)); + div.dataset.paddingLeft = findPositiveMin(ts, 32, 16) / boxScale; + div.dataset.paddingTop = findPositiveMin(ts, 48, 16) / boxScale; + div.dataset.paddingRight = findPositiveMin(ts, 0, 16) / boxScale; + div.dataset.paddingBottom = findPositiveMin(ts, 16, 16) / boxScale; + } + } + + function expandBounds(width, height, boxes) { + var bounds = boxes.map(function (box, i) { + return { + x1: box.left, + y1: box.top, + x2: box.right, + y2: box.bottom, + index: i, + x1New: undefined, + x2New: undefined + }; + }); + expandBoundsLTR(width, bounds); + var expanded = []; + expanded.length = boxes.length; + bounds.forEach(function (b) { + var i = b.index; + expanded[i] = { + left: b.x1New, + top: 0, + right: b.x2New, + bottom: 0 + }; + }); + + // Rotating on 90 degrees and extending extended boxes. Reusing the bounds + // array and objects. + boxes.map(function (box, i) { + var e = expanded[i], b = bounds[i]; + b.x1 = box.top; + b.y1 = width - e.right; + b.x2 = box.bottom; + b.y2 = width - e.left; + b.index = i; + b.x1New = undefined; + b.x2New = undefined; + }); + expandBoundsLTR(height, bounds); + + bounds.forEach(function (b) { + var i = b.index; + expanded[i].top = b.x1New; + expanded[i].bottom = b.x2New; + }); + return expanded; + } + + function expandBoundsLTR(width, bounds) { + // Sorting by x1 coordinate and walk by the bounds in the same order. + bounds.sort(function (a, b) { return a.x1 - b.x1 || a.index - b.index; }); + + // First we see on the horizon is a fake boundary. + var fakeBoundary = { + x1: -Infinity, + y1: -Infinity, + x2: 0, + y2: Infinity, + index: -1, + x1New: 0, + x2New: 0 + }; + var horizon = [{ + start: -Infinity, + end: Infinity, + boundary: fakeBoundary + }]; + + bounds.forEach(function (boundary) { + // Searching for the affected part of horizon. + // TODO red-black tree or simple binary search + var i = 0; + while (i < horizon.length && horizon[i].end <= boundary.y1) { + i++; + } + var j = horizon.length - 1; + while(j >= 0 && horizon[j].start >= boundary.y2) { + j--; + } + + var horizonPart, affectedBoundary; + var q, k, maxXNew = -Infinity; + for (q = i; q <= j; q++) { + horizonPart = horizon[q]; + affectedBoundary = horizonPart.boundary; + var xNew; + if (affectedBoundary.x2 > boundary.x1) { + // In the middle of the previous element, new x shall be at the + // boundary start. Extending if further if the affected bondary + // placed on top of the current one. + xNew = affectedBoundary.index > boundary.index ? + affectedBoundary.x1New : boundary.x1; + } else if (affectedBoundary.x2New === undefined) { + // We have some space in between, new x in middle will be a fair + // choice. + xNew = (affectedBoundary.x2 + boundary.x1) / 2; + } else { + // Affected boundary has x2new set, using it as new x. + xNew = affectedBoundary.x2New; + } + if (xNew > maxXNew) { + maxXNew = xNew; + } + } + + // Set new x1 for current boundary. + boundary.x1New = maxXNew; + + // Adjusts new x2 for the affected boundaries. + for (q = i; q <= j; q++) { + horizonPart = horizon[q]; + affectedBoundary = horizonPart.boundary; + if (affectedBoundary.x2New === undefined) { + // Was not set yet, choosing new x if possible. + if (affectedBoundary.x2 > boundary.x1) { + // Current and affected boundaries intersect. If affected boundary + // is placed on top of the current, shrinking the affected. + if (affectedBoundary.index > boundary.index) { + affectedBoundary.x2New = affectedBoundary.x2; + } + } else { + affectedBoundary.x2New = maxXNew; + } + } else if (affectedBoundary.x2New > maxXNew) { + // Affected boundary is touching new x, pushing it back. + affectedBoundary.x2New = Math.max(maxXNew, affectedBoundary.x2); + } + } + + // Fixing the horizon. + var changedHorizon = [], lastBoundary = null; + for (q = i; q <= j; q++) { + horizonPart = horizon[q]; + affectedBoundary = horizonPart.boundary; + // Checking which boundary will be visible. + var useBoundary = affectedBoundary.x2 > boundary.x2 ? + affectedBoundary : boundary; + if (lastBoundary === useBoundary) { + // Merging with previous. + changedHorizon[changedHorizon.length - 1].end = horizonPart.end; + } else { + changedHorizon.push({ + start: horizonPart.start, + end: horizonPart.end, + boundary: useBoundary + }); + lastBoundary = useBoundary; + } + } + if (horizon[i].start < boundary.y1) { + changedHorizon[0].start = boundary.y1; + changedHorizon.unshift({ + start: horizon[i].start, + end: boundary.y1, + boundary: horizon[i].boundary + }); + } + if (boundary.y2 < horizon[j].end) { + changedHorizon[changedHorizon.length - 1].end = boundary.y2; + changedHorizon.push({ + start: boundary.y2, + end: horizon[j].end, + boundary: horizon[j].boundary + }); + } + + // Set x2 new of boundary that is no longer visible (see overlapping case + // above). + // TODO more efficient, e.g. via reference counting. + for (q = i; q <= j; q++) { + horizonPart = horizon[q]; + affectedBoundary = horizonPart.boundary; + if (affectedBoundary.x2New !== undefined) { + continue; + } + var used = false; + for (k = i - 1; !used && k >= 0 && + horizon[k].start >= affectedBoundary.y1; k--) { + used = horizon[k].boundary === affectedBoundary; + } + for (k = j + 1; !used && k < horizon.length && + horizon[k].end <= affectedBoundary.y2; k++) { + used = horizon[k].boundary === affectedBoundary; + } + for (k = 0; !used && k < changedHorizon.length; k++) { + used = changedHorizon[k].boundary === affectedBoundary; + } + if (!used) { + affectedBoundary.x2New = maxXNew; + } + } + + Array.prototype.splice.apply(horizon, + [i, j - i + 1].concat(changedHorizon)); + }); + + // Set new x2 for all unset boundaries. + horizon.forEach(function (horizonPart) { + var affectedBoundary = horizonPart.boundary; + if (affectedBoundary.x2New === undefined) { + affectedBoundary.x2New = Math.max(width, affectedBoundary.x2); + } + }); + } + /** * Text layer rendering task. * @@ -182,9 +499,14 @@ var renderTextLayer = (function renderTextLayerClosure() { * @param {HTMLElement} container * @param {PageViewport} viewport * @param {Array} textDivs + * @param {boolean} enhanceTextSelection * @private */ - function TextLayerRenderTask(textContent, container, viewport, textDivs) { + function TextLayerRenderTask(textContent, + container, + viewport, + textDivs, + enhanceTextSelection) { this._textContent = textContent; this._container = container; this._viewport = viewport; @@ -193,6 +515,7 @@ var renderTextLayer = (function renderTextLayerClosure() { this._canceled = false; this._capability = createPromiseCapability(); this._renderTimer = null; + this._enhanceTextSelection = !!enhanceTextSelection; } TextLayerRenderTask.prototype = { get promise() { @@ -213,10 +536,19 @@ var renderTextLayer = (function renderTextLayerClosure() { var styles = this._textContent.styles; var textDivs = this._textDivs; var viewport = this._viewport; + var bounds = []; + var enhanceTextSelection = this._enhanceTextSelection; for (var i = 0, len = textItems.length; i < len; i++) { - appendText(textDivs, viewport, textItems[i], styles); + appendText(textDivs, + viewport, + textItems[i], + styles, + bounds, + enhanceTextSelection); + } + if (enhanceTextSelection) { + expand(bounds, this._viewport); } - if (!timeout) { // Render right away render(this); } else { // Schedule @@ -240,7 +572,8 @@ var renderTextLayer = (function renderTextLayerClosure() { var task = new TextLayerRenderTask(renderParameters.textContent, renderParameters.container, renderParameters.viewport, - renderParameters.textDivs); + renderParameters.textDivs, + renderParameters.enhanceTextSelection); task._render(renderParameters.timeout); return task; } diff --git a/web/app.js b/web/app.js index f447830229c5a5..330e76eef92b51 100644 --- a/web/app.js +++ b/web/app.js @@ -176,6 +176,7 @@ var PDFViewerApplication = { preferencePdfBugEnabled: false, preferenceShowPreviousViewOnLoad: true, preferenceDefaultZoomValue: '', + preferenceEnhanceTextSelection: false, isViewerEmbedded: (window.parent !== window), url: '', externalServices: DefaultExernalServices, @@ -203,13 +204,16 @@ var PDFViewerApplication = { var container = appConfig.mainContainer; var viewer = appConfig.viewerContainer; + console.log('Enhanced Text Selection is set to:', + this.preferenceEnhanceTextSelection); this.pdfViewer = new PDFViewer({ container: container, viewer: viewer, eventBus: eventBus, renderingQueue: pdfRenderingQueue, linkService: pdfLinkService, - downloadManager: downloadManager + downloadManager: downloadManager, + enhanceTextSelection: this.preferenceEnhanceTextSelection }); pdfRenderingQueue.setViewer(this.pdfViewer); pdfLinkService.setViewer(this.pdfViewer); diff --git a/web/interfaces.js b/web/interfaces.js index 94d958cde644e8..d7922f5e36ecae 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -98,9 +98,13 @@ IPDFTextLayerFactory.prototype = { * @param {HTMLDivElement} textLayerDiv * @param {number} pageIndex * @param {PageViewport} viewport + * @param {Boolean} enhanceTextSelection * @returns {TextLayerBuilder} */ - createTextLayerBuilder: function (textLayerDiv, pageIndex, viewport) {} + createTextLayerBuilder: function (textLayerDiv, + pageIndex, + viewport, + enhanceTextSelection) {} }; /** diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 467b8c029d1737..656284a7853106 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -50,6 +50,8 @@ var TEXT_LAYER_RENDER_DELAY = 200; // ms * @property {PDFRenderingQueue} renderingQueue - The rendering queue object. * @property {IPDFTextLayerFactory} textLayerFactory * @property {IPDFAnnotationLayerFactory} annotationLayerFactory + * @property {boolean} enhanceTextSelection - Turns on text enhancement. Default + * is false. */ /** @@ -69,6 +71,7 @@ var PDFPageView = (function PDFPageViewClosure() { var renderingQueue = options.renderingQueue; var textLayerFactory = options.textLayerFactory; var annotationLayerFactory = options.annotationLayerFactory; + var enhanceTextSelection = options.enhanceTextSelection; this.id = id; this.renderingId = 'page' + id; @@ -78,6 +81,7 @@ var PDFPageView = (function PDFPageViewClosure() { this.viewport = defaultViewport; this.pdfPageRotate = defaultViewport.rotation; this.hasRestrictedScaling = false; + this.enhanceTextSelection = enhanceTextSelection; this.eventBus = options.eventBus || domEvents.getGlobalEventBus(); this.renderingQueue = renderingQueue; @@ -395,9 +399,11 @@ var PDFPageView = (function PDFPageViewClosure() { div.appendChild(textLayerDiv); } - textLayer = this.textLayerFactory.createTextLayerBuilder(textLayerDiv, - this.id - 1, - this.viewport); + textLayer = this.textLayerFactory. + createTextLayerBuilder(textLayerDiv, + this.id - 1, + this.viewport, + this.enhanceTextSelection); } this.textLayer = textLayer; diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 1d70f6e5f28bb6..6fb8bd21515ba4 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -76,6 +76,8 @@ var DEFAULT_CACHE_SIZE = 10; * queue object. * @property {boolean} removePageBorders - (optional) Removes the border shadow * around the pages. The default is false. + * @property {boolean} enhanceTextSelection (optional) enhances text selection. + * The default is false. */ /** @@ -127,6 +129,7 @@ var PDFViewer = (function pdfViewer() { this.linkService = options.linkService || new SimpleLinkService(); this.downloadManager = options.downloadManager || null; this.removePageBorders = options.removePageBorders || false; + this.enhanceTextSelection = options.enhanceTextSelection || false; this.defaultRenderingQueue = !options.renderingQueue; if (this.defaultRenderingQueue) { @@ -352,7 +355,8 @@ var PDFViewer = (function pdfViewer() { defaultViewport: viewport.clone(), renderingQueue: this.renderingQueue, textLayerFactory: textLayerFactory, - annotationLayerFactory: this + annotationLayerFactory: this, + enhanceTextSelection: this.enhanceTextSelection }); bindOnAfterAndBeforeDraw(pageView); this._pages.push(pageView); @@ -798,13 +802,17 @@ var PDFViewer = (function pdfViewer() { * @param {PageViewport} viewport * @returns {TextLayerBuilder} */ - createTextLayerBuilder: function (textLayerDiv, pageIndex, viewport) { + createTextLayerBuilder: function (textLayerDiv, + pageIndex, + viewport, + enhanceTextSelection) { return new TextLayerBuilder({ textLayerDiv: textLayerDiv, eventBus: this.eventBus, pageIndex: pageIndex, viewport: viewport, - findController: this.isInPresentationMode ? null : this.findController + findController: this.isInPresentationMode ? null : this.findController, + enhanceTextSelection: enhanceTextSelection }); }, diff --git a/web/text_layer_builder.js b/web/text_layer_builder.js index 14c416a0ba2e66..e78889a2002555 100644 --- a/web/text_layer_builder.js +++ b/web/text_layer_builder.js @@ -15,6 +15,13 @@ 'use strict'; +var MAX_TEXT_DIVS_TO_RENDER = 100000; + +var NonWhitespaceRegexp = /\S/; + +function isAllWhitespace(str) { + return !NonWhitespaceRegexp.test(str); +} (function (root, factory) { if (typeof define === 'function' && define.amd) { define('pdfjs-web/text_layer_builder', ['exports', 'pdfjs-web/dom_events', @@ -35,6 +42,8 @@ * @property {number} pageIndex - The page index. * @property {PageViewport} viewport - The viewport of the text layer. * @property {PDFFindController} findController + * @property {boolean} enhanceTextSelection - Option to turn on improved + * text selection. */ /** @@ -57,6 +66,7 @@ var TextLayerBuilder = (function TextLayerBuilderClosure() { this.textDivs = []; this.findController = options.findController || null; this.textLayerRenderTask = null; + this.enhanceTextSelection = !!options.enhanceTextSelection; this._bindMouse(); } @@ -96,14 +106,15 @@ var TextLayerBuilder = (function TextLayerBuilderClosure() { container: textLayerFrag, viewport: this.viewport, textDivs: this.textDivs, - timeout: timeout + timeout: timeout, + enhanceTextSelection: this.enhanceTextSelection }); this.textLayerRenderTask.promise.then(function () { this.textLayerDiv.appendChild(textLayerFrag); this._finishRendering(); this.updateMatches(); }.bind(this), function (reason) { - // canceled or failed to render text layer -- skipping errors + // canceled or failed to render text layer -- skipping errors }); }, @@ -349,6 +360,7 @@ var TextLayerBuilder = (function TextLayerBuilderClosure() { }); }, }; + return TextLayerBuilder; })(); @@ -362,13 +374,18 @@ DefaultTextLayerFactory.prototype = { * @param {HTMLDivElement} textLayerDiv * @param {number} pageIndex * @param {PageViewport} viewport + * @param {boolean} enhanceTextSelection * @returns {TextLayerBuilder} */ - createTextLayerBuilder: function (textLayerDiv, pageIndex, viewport) { + createTextLayerBuilder: function (textLayerDiv, + pageIndex, + viewport, + enhanceTextSelection) { return new TextLayerBuilder({ textLayerDiv: textLayerDiv, pageIndex: pageIndex, - viewport: viewport + viewport: viewport, + enhanceTextSelection: enhanceTextSelection }); } };