diff --git a/src/canvas.js b/src/canvas.js index f16313db2..b457eb4ff 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -174,7 +174,7 @@ var CanvasGraphics = (function canvasGraphics() { // before it stops and shedules a continue of execution. var kExecutionTime = 50; - function constructor(canvasCtx, objs) { + function constructor(canvasCtx, objs, textLayer) { this.ctx = canvasCtx; this.current = new CanvasExtraState(); this.stateStack = []; @@ -183,7 +183,7 @@ var CanvasGraphics = (function canvasGraphics() { this.xobjs = null; this.ScratchCanvas = ScratchCanvas; this.objs = objs; - + this.textLayer = textLayer; if (canvasCtx) { addContextCurrentTransform(canvasCtx); } @@ -212,7 +212,13 @@ var CanvasGraphics = (function canvasGraphics() { this.ctx.transform(0, -1, -1, 0, cw, ch); break; } + // Scale so that canvas units are the same as PDF user space units this.ctx.scale(cw / mediaBox.width, ch / mediaBox.height); + this.textDivs = []; + this.textLayerQueue = []; + // Prevent textLayerQueue from being rendered while rendering a new page + if (this.textLayerTimer) + clearTimeout(this.textLayerTimer); }, executeIRQueue: function canvasGraphicsExecuteIRQueue(codeIR, @@ -270,6 +276,37 @@ var CanvasGraphics = (function canvasGraphics() { endDrawing: function canvasGraphicsEndDrawing() { this.ctx.restore(); + + var textLayer = this.textLayer; + if (!textLayer) + return; + + var self = this; + var renderTextLayer = function canvasRenderTextLayer() { + var textDivs = self.textDivs; + for (var i = 0, length = textDivs.length; i < length; ++i) { + if (textDivs[i].dataset.textLength > 1) { // avoid div by zero + textLayer.appendChild(textDivs[i]); + // Adjust div width (via letterSpacing) to match canvas text + // Due to the .offsetWidth calls, this is slow + textDivs[i].style.letterSpacing = + ((textDivs[i].dataset.canvasWidth - textDivs[i].offsetWidth) / + (textDivs[i].dataset.textLength - 1)) + 'px'; + } + } + } + var textLayerQueue = this.textLayerQueue; + textLayerQueue.push(renderTextLayer); + + // Lazy textLayer rendering (to prevent UI hangs) + // Only render queue if activity has stopped, where "no activity" == + // "no beginDrawing() calls in the last N ms" + this.textLayerTimer = setTimeout(function renderTextLayerQueue() { + // Render most recent (==most relevant) layers first + for (var i = textLayerQueue.length - 1; i >= 0; i--) { + textLayerQueue.pop().call(); + } + }, 500); }, // Graphics state @@ -528,11 +565,68 @@ var CanvasGraphics = (function canvasGraphics() { nextLine: function canvasGraphicsNextLine() { this.moveText(0, this.current.leading); }, - showText: function canvasGraphicsShowText(text) { + applyTextTransforms: function canvasApplyTransforms() { + var ctx = this.ctx; + var current = this.current; + var textHScale = current.textHScale; + var fontMatrix = current.font.fontMatrix || IDENTITY_MATRIX; + + ctx.transform.apply(ctx, current.textMatrix); + ctx.scale(1, -1); + ctx.translate(current.x, -1 * current.y); + ctx.transform.apply(ctx, fontMatrix); + ctx.scale(1 / textHScale, 1); + }, + getTextGeometry: function canvasGetTextGeometry() { + var geometry = {}; + var ctx = this.ctx; + var font = this.current.font; + var ctxMatrix = ctx.mozCurrentTransform; + if (ctxMatrix) { + var bl = Util.applyTransform([0, 0], ctxMatrix); + var tr = Util.applyTransform([1, 1], ctxMatrix); + geometry.x = bl[0]; + geometry.y = bl[1]; + geometry.hScale = tr[0] - bl[0]; + geometry.vScale = tr[1] - bl[1]; + } + var spaceGlyph = font.charsToGlyphs(' '); + + // Hack (sometimes space is not encoded) + if (spaceGlyph.length === 0 || spaceGlyph[0].width === 0) + spaceGlyph = font.charsToGlyphs('i'); + + // Fallback + if (spaceGlyph.length === 0 || spaceGlyph[0].width === 0) + spaceGlyph = [{width: 0}]; + + geometry.spaceWidth = spaceGlyph[0].width; + return geometry; + }, + + pushTextDivs: function canvasGraphicsPushTextDivs(text) { + var div = document.createElement('div'); + var fontSize = this.current.fontSize; + + // vScale and hScale already contain the scaling to pixel units + // as mozCurrentTransform reflects ctx.scale() changes + // (see beginDrawing()) + var fontHeight = fontSize * text.geom.vScale; + div.dataset.canvasWidth = text.canvasWidth * text.geom.hScale; + + div.style.fontSize = fontHeight + 'px'; + div.style.fontFamily = this.current.font.loadedName || 'sans-serif'; + div.style.left = text.geom.x + 'px'; + div.style.top = (text.geom.y - fontHeight) + 'px'; + div.innerHTML = text.str; + div.dataset.textLength = text.length; + this.textDivs.push(div); + }, + showText: function canvasGraphicsShowText(str, skipTextSelection) { var ctx = this.ctx; var current = this.current; var font = current.font; - var glyphs = font.charsToGlyphs(text); + var glyphs = font.charsToGlyphs(str); var fontSize = current.fontSize; var charSpacing = current.charSpacing; var wordSpacing = current.wordSpacing; @@ -540,6 +634,18 @@ var CanvasGraphics = (function canvasGraphics() { var fontMatrix = font.fontMatrix || IDENTITY_MATRIX; var textHScale2 = textHScale * fontMatrix[0]; var glyphsLength = glyphs.length; + var textLayer = this.textLayer; + var text = {str: '', length: 0, canvasWidth: 0, geom: {}}; + var textSelection = textLayer && !skipTextSelection ? true : false; + + if (textSelection) { + ctx.save(); + this.applyTextTransforms(); + text.geom = this.getTextGeometry(); + ctx.restore(); + } + + // Type3 fonts - each glyph is a "mini-PDF" if (font.coded) { ctx.save(); ctx.transform.apply(ctx, current.textMatrix); @@ -567,16 +673,14 @@ var CanvasGraphics = (function canvasGraphics() { ctx.translate(width, 0); current.x += width * textHScale2; + text.str += glyph.unicode; + text.length++; + text.canvasWidth += width; } ctx.restore(); } else { ctx.save(); - ctx.transform.apply(ctx, current.textMatrix); - ctx.scale(1, -1); - ctx.translate(current.x, -1 * current.y); - ctx.transform.apply(ctx, fontMatrix); - - ctx.scale(1 / textHScale, 1); + this.applyTextTransforms(); var width = 0; for (var i = 0; i < glyphsLength; ++i) { @@ -588,17 +692,23 @@ var CanvasGraphics = (function canvasGraphics() { } var char = glyph.fontChar; + var charWidth = glyph.width * fontSize * 0.001 + charSpacing; ctx.fillText(char, width, 0); - width += glyph.width * fontSize * 0.001 + charSpacing; + width += charWidth; - // TODO actual characters can be extracted from the glyph.unicode + text.str += glyph.unicode === ' ' ? ' ' : glyph.unicode; + text.length++; + text.canvasWidth += charWidth; } current.x += width * textHScale2; - ctx.restore(); } - }, + if (textSelection) + this.pushTextDivs(text); + + return text; + }, showSpacedText: function canvasGraphicsShowSpacedText(arr) { var ctx = this.ctx; var current = this.current; @@ -606,16 +716,53 @@ var CanvasGraphics = (function canvasGraphics() { var textHScale2 = current.textHScale * (current.font.fontMatrix || IDENTITY_MATRIX)[0]; var arrLength = arr.length; + var textLayer = this.textLayer; + var font = current.font; + var text = {str: '', length: 0, canvasWidth: 0, geom: {}}; + var textSelection = textLayer ? true : false; + + if (textSelection) { + ctx.save(); + this.applyTextTransforms(); + text.geom = this.getTextGeometry(); + ctx.restore(); + } + for (var i = 0; i < arrLength; ++i) { var e = arr[i]; if (isNum(e)) { - current.x -= e * 0.001 * fontSize * textHScale2; + var spacingLength = -e * 0.001 * fontSize * textHScale2; + current.x += spacingLength; + + if (textSelection) { + // Emulate precise spacing via HTML spaces + text.canvasWidth += spacingLength; + if (e < 0 && text.geom.spaceWidth > 0) { // avoid div by zero + var numFakeSpaces = Math.round(-e / text.geom.spaceWidth); + for (var j = 0; j < numFakeSpaces; ++j) + text.str += ' '; + text.length += numFakeSpaces > 0 ? 1 : 0; + } + } } else if (isString(e)) { - this.showText(e); + var shownText = this.showText(e, true); + + if (textSelection) { + if (shownText.str === ' ') { + text.str += ' '; + } else { + text.str += shownText.str; + } + text.canvasWidth += shownText.canvasWidth; + text.length += e.length; + } } else { malformed('TJ array element ' + e + ' is not string or num'); } } + + if (textSelection) + this.pushTextDivs(text); }, nextLineShowText: function canvasGraphicsNextLineShowText(text) { this.nextLine(); diff --git a/src/core.js b/src/core.js index a3485b3e0..6b7845efd 100644 --- a/src/core.js +++ b/src/core.js @@ -156,7 +156,7 @@ var Page = (function pagePage() { IRQueue, fonts) { var self = this; this.IRQueue = IRQueue; - var gfx = new CanvasGraphics(this.ctx, this.objs); + var gfx = new CanvasGraphics(this.ctx, this.objs, this.textLayer); var displayContinuation = function pageDisplayContinuation() { // Always defer call to display() to work around bug in @@ -241,6 +241,7 @@ var Page = (function pagePage() { startIdx = gfx.executeIRQueue(IRQueue, startIdx, next); if (startIdx == length) { self.stats.render = Date.now(); + gfx.endDrawing(); if (callback) callback(); } } @@ -303,9 +304,10 @@ var Page = (function pagePage() { } return links; }, - startRendering: function pageStartRendering(ctx, callback) { + startRendering: function pageStartRendering(ctx, callback, textLayer) { this.ctx = ctx; this.callback = callback; + this.textLayer = textLayer; this.startRenderingTime = Date.now(); this.pdf.startRendering(this); diff --git a/src/metrics.js b/src/metrics.js index c21b4aed1..e64961aa7 100644 --- a/src/metrics.js +++ b/src/metrics.js @@ -3,6 +3,9 @@ 'use strict'; +// The Metrics object contains glyph widths (in glyph space units). +// As per PDF spec, for most fonts (Type 3 being an exception) a glyph +// space unit corresponds to 1/1000th of text space unit. var Metrics = { 'Courier': 600, 'Courier-Bold': 600, diff --git a/web/viewer.css b/web/viewer.css index c379e91c4..3313ce3cb 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -232,6 +232,27 @@ canvas { -webkit-box-shadow: 0px 2px 10px #ff0; } +.textLayer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + color: #000; +} + +.textLayer > div { + color: transparent; + position: absolute; + line-height:1.3; +} + +/* TODO: file FF bug to support ::-moz-selection:window-inactive + so we can override the opaque grey background when the window is inactive; + see https://bugzilla.mozilla.org/show_bug.cgi?id=706209 */ +::selection { background:rgba(0,0,255,0.3); } +::-moz-selection { background:rgba(0,0,255,0.3); } + #viewer { margin: 44px 0px 0px; padding: 8px 0px; diff --git a/web/viewer.js b/web/viewer.js index 2e8ce0df6..7b15ee5ac 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -489,6 +489,10 @@ var PageView = function pageView(container, content, id, pageWidth, pageHeight, div.appendChild(canvas); this.canvas = canvas; + var textLayer = document.createElement('div'); + textLayer.className = 'textLayer'; + div.appendChild(textLayer); + var scale = this.scale; canvas.width = pageWidth * scale; canvas.height = pageHeight * scale; @@ -505,7 +509,7 @@ var PageView = function pageView(container, content, id, pageWidth, pageHeight, this.updateStats(); if (this.onAfterDraw) this.onAfterDraw(); - }).bind(this)); + }).bind(this), textLayer); setupLinks(this.content, this.scale); div.setAttribute('data-loaded', true);