image_util.js revision 1320f92c476a1ad9d19dba2a48c72b75566198e9
1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7
8// Namespace object for the utilities.
9function ImageUtil() {}
10
11/**
12 * Performance trace.
13 */
14ImageUtil.trace = (function() {
15  function PerformanceTrace() {
16    this.lines_ = {};
17    this.timers_ = {};
18    this.container_ = null;
19  }
20
21  PerformanceTrace.prototype.bindToDOM = function(container) {
22    this.container_ = container;
23  };
24
25  PerformanceTrace.prototype.report = function(key, value) {
26    if (!(key in this.lines_)) {
27      if (this.container_) {
28        var div = this.lines_[key] = document.createElement('div');
29        this.container_.appendChild(div);
30      } else {
31        this.lines_[key] = {};
32      }
33    }
34    this.lines_[key].textContent = key + ': ' + value;
35    if (ImageUtil.trace.log) this.dumpLine(key);
36  };
37
38  PerformanceTrace.prototype.resetTimer = function(key) {
39    this.timers_[key] = Date.now();
40  };
41
42  PerformanceTrace.prototype.reportTimer = function(key) {
43    this.report(key, (Date.now() - this.timers_[key]) + 'ms');
44  };
45
46  PerformanceTrace.prototype.dump = function() {
47    for (var key in this.lines_)
48      this.dumpLine(key);
49  };
50
51  PerformanceTrace.prototype.dumpLine = function(key) {
52    console.log('trace.' + this.lines_[key].textContent);
53  };
54
55  return new PerformanceTrace();
56})();
57
58/**
59 * @param {number} min Minimum value.
60 * @param {number} value Value to adjust.
61 * @param {number} max Maximum value.
62 * @return {number} The closest to the |value| number in span [min, max].
63 */
64ImageUtil.clamp = function(min, value, max) {
65  return Math.max(min, Math.min(max, value));
66};
67
68/**
69 * @param {number} min Minimum value.
70 * @param {number} value Value to check.
71 * @param {number} max Maximum value.
72 * @return {boolean} True if value is between.
73 */
74ImageUtil.between = function(min, value, max) {
75  return (value - min) * (value - max) <= 0;
76};
77
78/**
79 * Rectangle class.
80 */
81
82/**
83 * Rectangle constructor takes 0, 1, 2 or 4 arguments.
84 * Supports following variants:
85 *   new Rect(left, top, width, height)
86 *   new Rect(width, height)
87 *   new Rect(rect)         // anything with left, top, width, height properties
88 *   new Rect(bounds)       // anything with left, top, right, bottom properties
89 *   new Rect(canvas|image) // anything with width and height properties.
90 *   new Rect()             // empty rectangle.
91 * @constructor
92 */
93function Rect() {
94  switch (arguments.length) {
95    case 4:
96      this.left = arguments[0];
97      this.top = arguments[1];
98      this.width = arguments[2];
99      this.height = arguments[3];
100      return;
101
102    case 2:
103      this.left = 0;
104      this.top = 0;
105      this.width = arguments[0];
106      this.height = arguments[1];
107      return;
108
109    case 1: {
110      var source = arguments[0];
111      if ('left' in source && 'top' in source) {
112        this.left = source.left;
113        this.top = source.top;
114        if ('right' in source && 'bottom' in source) {
115          this.width = source.right - source.left;
116          this.height = source.bottom - source.top;
117          return;
118        }
119      } else {
120        this.left = 0;
121        this.top = 0;
122      }
123      if ('width' in source && 'height' in source) {
124        this.width = source.width;
125        this.height = source.height;
126        return;
127      }
128      break; // Fall through to the error message.
129    }
130
131    case 0:
132      this.left = 0;
133      this.top = 0;
134      this.width = 0;
135      this.height = 0;
136      return;
137  }
138  console.error('Invalid Rect constructor arguments:',
139                Array.apply(null, arguments));
140}
141
142Rect.prototype = {
143  /**
144   * Obtains the x coordinate of right edge. The most right pixels in the
145   * rectangle are (x = right - 1) and the pixels (x = right) are not included
146   * in the rectangle.
147   * @return {number}
148   */
149  get right() {
150    return this.left + this.width;
151  },
152
153  /**
154   * Obtains the y coordinate of bottom edge. The most bottom pixels in the
155   * rectangle are (y = bottom - 1) and the pixels (y = bottom) are not included
156   * in the rectangle.
157   * @return {number}
158   */
159  get bottom() {
160    return this.top + this.height;
161  }
162};
163
164/**
165 * @param {number} factor Factor to scale.
166 * @return {Rect} A rectangle with every dimension scaled.
167 */
168Rect.prototype.scale = function(factor) {
169  return new Rect(
170      this.left * factor,
171      this.top * factor,
172      this.width * factor,
173      this.height * factor);
174};
175
176/**
177 * @param {number} dx Difference in X.
178 * @param {number} dy Difference in Y.
179 * @return {Rect} A rectangle shifted by (dx,dy), same size.
180 */
181Rect.prototype.shift = function(dx, dy) {
182  return new Rect(this.left + dx, this.top + dy, this.width, this.height);
183};
184
185/**
186 * @param {number} x Coordinate of the left top corner.
187 * @param {number} y Coordinate of the left top corner.
188 * @return {Rect} A rectangle with left==x and top==y, same size.
189 */
190Rect.prototype.moveTo = function(x, y) {
191  return new Rect(x, y, this.width, this.height);
192};
193
194/**
195 * @param {number} dx Difference in X.
196 * @param {number} dy Difference in Y.
197 * @return {Rect} A rectangle inflated by (dx, dy), same center.
198 */
199Rect.prototype.inflate = function(dx, dy) {
200  return new Rect(
201      this.left - dx, this.top - dy, this.width + 2 * dx, this.height + 2 * dy);
202};
203
204/**
205 * @param {number} x Coordinate of the point.
206 * @param {number} y Coordinate of the point.
207 * @return {boolean} True if the point lies inside the rectangle.
208 */
209Rect.prototype.inside = function(x, y) {
210  return this.left <= x && x < this.left + this.width &&
211         this.top <= y && y < this.top + this.height;
212};
213
214/**
215 * @param {Rect} rect Rectangle to check.
216 * @return {boolean} True if this rectangle intersects with the |rect|.
217 */
218Rect.prototype.intersects = function(rect) {
219  return (this.left + this.width) > rect.left &&
220         (rect.left + rect.width) > this.left &&
221         (this.top + this.height) > rect.top &&
222         (rect.top + rect.height) > this.top;
223};
224
225/**
226 * @param {Rect} rect Rectangle to check.
227 * @return {boolean} True if this rectangle containing the |rect|.
228 */
229Rect.prototype.contains = function(rect) {
230  return (this.left <= rect.left) &&
231         (rect.left + rect.width) <= (this.left + this.width) &&
232         (this.top <= rect.top) &&
233         (rect.top + rect.height) <= (this.top + this.height);
234};
235
236/**
237 * @return {boolean} True if rectangle is empty.
238 */
239Rect.prototype.isEmpty = function() {
240  return this.width === 0 || this.height === 0;
241};
242
243/**
244 * Clamp the rectangle to the bounds by moving it.
245 * Decrease the size only if necessary.
246 * @param {Rect} bounds Bounds.
247 * @return {Rect} Calculated rectangle.
248 */
249Rect.prototype.clamp = function(bounds) {
250  var rect = new Rect(this);
251
252  if (rect.width > bounds.width) {
253    rect.left = bounds.left;
254    rect.width = bounds.width;
255  } else if (rect.left < bounds.left) {
256    rect.left = bounds.left;
257  } else if (rect.left + rect.width >
258             bounds.left + bounds.width) {
259    rect.left = bounds.left + bounds.width - rect.width;
260  }
261
262  if (rect.height > bounds.height) {
263    rect.top = bounds.top;
264    rect.height = bounds.height;
265  } else if (rect.top < bounds.top) {
266    rect.top = bounds.top;
267  } else if (rect.top + rect.height >
268             bounds.top + bounds.height) {
269    rect.top = bounds.top + bounds.height - rect.height;
270  }
271
272  return rect;
273};
274
275/**
276 * @return {string} String representation.
277 */
278Rect.prototype.toString = function() {
279  return '(' + this.left + ',' + this.top + '):' +
280         '(' + (this.left + this.width) + ',' + (this.top + this.height) + ')';
281};
282/*
283 * Useful shortcuts for drawing (static functions).
284 */
285
286/**
287 * Draw the image in context with appropriate scaling.
288 * @param {CanvasRenderingContext2D} context Context to draw.
289 * @param {Image} image Image to draw.
290 * @param {Rect=} opt_dstRect Rectangle in the canvas (whole canvas by default).
291 * @param {Rect=} opt_srcRect Rectangle in the image (whole image by default).
292 */
293Rect.drawImage = function(context, image, opt_dstRect, opt_srcRect) {
294  opt_dstRect = opt_dstRect || new Rect(context.canvas);
295  opt_srcRect = opt_srcRect || new Rect(image);
296  if (opt_dstRect.isEmpty() || opt_srcRect.isEmpty())
297    return;
298  context.drawImage(image,
299      opt_srcRect.left, opt_srcRect.top, opt_srcRect.width, opt_srcRect.height,
300      opt_dstRect.left, opt_dstRect.top, opt_dstRect.width, opt_dstRect.height);
301};
302
303/**
304 * Draw a box around the rectangle.
305 * @param {CanvasRenderingContext2D} context Context to draw.
306 * @param {Rect} rect Rectangle.
307 */
308Rect.outline = function(context, rect) {
309  context.strokeRect(
310      rect.left - 0.5, rect.top - 0.5, rect.width + 1, rect.height + 1);
311};
312
313/**
314 * Fill the rectangle.
315 * @param {CanvasRenderingContext2D} context Context to draw.
316 * @param {Rect} rect Rectangle.
317 */
318Rect.fill = function(context, rect) {
319  context.fillRect(rect.left, rect.top, rect.width, rect.height);
320};
321
322/**
323 * Fills the space between the two rectangles.
324 * @param {CanvasRenderingContext2D} context Context to draw.
325 * @param {Rect} inner Inner rectangle.
326 * @param {Rect} outer Outer rectangle.
327 */
328Rect.fillBetween = function(context, inner, outer) {
329  var innerRight = inner.left + inner.width;
330  var innerBottom = inner.top + inner.height;
331  var outerRight = outer.left + outer.width;
332  var outerBottom = outer.top + outer.height;
333  if (inner.top > outer.top) {
334    context.fillRect(
335        outer.left, outer.top, outer.width, inner.top - outer.top);
336  }
337  if (inner.left > outer.left) {
338    context.fillRect(
339        outer.left, inner.top, inner.left - outer.left, inner.height);
340  }
341  if (inner.width < outerRight) {
342    context.fillRect(
343        innerRight, inner.top, outerRight - innerRight, inner.height);
344  }
345  if (inner.height < outerBottom) {
346    context.fillRect(
347        outer.left, innerBottom, outer.width, outerBottom - innerBottom);
348  }
349};
350
351/**
352 * Circle class.
353 * @param {number} x X coordinate of circle center.
354 * @param {number} y Y coordinate of circle center.
355 * @param {number} r Radius.
356 * @constructor
357 */
358function Circle(x, y, r) {
359  this.x = x;
360  this.y = y;
361  this.squaredR = r * r;
362}
363
364/**
365 * Check if the point is inside the circle.
366 * @param {number} x X coordinate of the point.
367 * @param {number} y Y coordinate of the point.
368 * @return {boolean} True if the point is inside.
369 */
370Circle.prototype.inside = function(x, y) {
371  x -= this.x;
372  y -= this.y;
373  return x * x + y * y <= this.squaredR;
374};
375
376/**
377 * Copy an image applying scaling and rotation.
378 *
379 * @param {HTMLCanvasElement} dst Destination.
380 * @param {HTMLCanvasElement|HTMLImageElement} src Source.
381 * @param {number} scaleX Y scale transformation.
382 * @param {number} scaleY X scale transformation.
383 * @param {number} angle (in radians).
384 */
385ImageUtil.drawImageTransformed = function(dst, src, scaleX, scaleY, angle) {
386  var context = dst.getContext('2d');
387  context.save();
388  context.translate(context.canvas.width / 2, context.canvas.height / 2);
389  context.rotate(angle);
390  context.scale(scaleX, scaleY);
391  context.drawImage(src, -src.width / 2, -src.height / 2);
392  context.restore();
393};
394
395/**
396 * Adds or removes an attribute to/from an HTML element.
397 * @param {HTMLElement} element To be applied to.
398 * @param {string} attribute Name of attribute.
399 * @param {boolean} on True if add, false if remove.
400 */
401ImageUtil.setAttribute = function(element, attribute, on) {
402  if (on)
403    element.setAttribute(attribute, '');
404  else
405    element.removeAttribute(attribute);
406};
407
408/**
409 * Adds or removes CSS class to/from an HTML element.
410 * @param {HTMLElement} element To be applied to.
411 * @param {string} className Name of CSS class.
412 * @param {boolean} on True if add, false if remove.
413 */
414ImageUtil.setClass = function(element, className, on) {
415  var cl = element.classList;
416  if (on)
417    cl.add(className);
418  else
419    cl.remove(className);
420};
421
422/**
423 * ImageLoader loads an image from a given Entry into a canvas in two steps:
424 * 1. Loads the image into an HTMLImageElement.
425 * 2. Copies pixels from HTMLImageElement to HTMLCanvasElement. This is done
426 *    stripe-by-stripe to avoid freezing up the UI. The transform is taken into
427 *    account.
428 *
429 * @param {HTMLDocument} document Owner document.
430 * @constructor
431 */
432ImageUtil.ImageLoader = function(document) {
433  this.document_ = document;
434  this.image_ = new Image();
435  this.generation_ = 0;
436};
437
438/**
439 * Loads an image.
440 * TODO(mtomasz): Simplify, or even get rid of this class and merge with the
441 * ThumbnaiLoader class.
442 *
443 * @param {Gallery.Item} item Item representing the image to be loaded.
444 * @param {function(HTMLCanvasElement, string=)} callback Callback to be
445 *     called when loaded. The second optional argument is an error identifier.
446 * @param {number=} opt_delay Load delay in milliseconds, useful to let the
447 *     animations play out before the computation heavy image loading starts.
448 */
449ImageUtil.ImageLoader.prototype.load = function(item, callback, opt_delay) {
450  var entry = item.getEntry();
451
452  this.cancel();
453  this.entry_ = entry;
454  this.callback_ = callback;
455
456  // The transform fetcher is not cancellable so we need a generation counter.
457  var generation = ++this.generation_;
458  var onTransform = function(image, transform) {
459    if (generation === this.generation_) {
460      this.convertImage_(
461          image, transform || { scaleX: 1, scaleY: 1, rotate90: 0});
462    }
463  }.bind(this);
464
465  var onError = function(opt_error) {
466    this.image_.onerror = null;
467    this.image_.onload = null;
468    var tmpCallback = this.callback_;
469    this.callback_ = null;
470    var emptyCanvas = this.document_.createElement('canvas');
471    emptyCanvas.width = 0;
472    emptyCanvas.height = 0;
473    tmpCallback(emptyCanvas, opt_error);
474  }.bind(this);
475
476  var loadImage = function() {
477    ImageUtil.metrics.startInterval(ImageUtil.getMetricName('LoadTime'));
478    this.timeout_ = null;
479
480    this.image_.onload = function() {
481      this.image_.onerror = null;
482      this.image_.onload = null;
483      item.getFetchedMedia().then(function(fetchedMediaMetadata) {
484        onTransform(this.image_, fetchedMediaMetadata.imageTransform);
485      }.bind(this)).catch(function(error) {
486        console.error(error.stack || error);
487      });
488    }.bind(this);
489
490    // The error callback has an optional error argument, which in case of a
491    // general error should not be specified
492    this.image_.onerror = onError.bind(this, 'GALLERY_IMAGE_ERROR');
493
494    // Load the image directly. The query parameter is workaround for
495    // crbug.com/379678, which force to update the contents of the image.
496    this.image_.src = entry.toURL() + '?nocache=' + Date.now();
497  }.bind(this);
498
499  // Loads the image. If already loaded, then forces a reload.
500  var startLoad = this.resetImage_.bind(this, function() {
501    loadImage();
502  }.bind(this), onError);
503
504  if (opt_delay) {
505    this.timeout_ = setTimeout(startLoad, opt_delay);
506  } else {
507    startLoad();
508  }
509};
510
511/**
512 * Resets the image by forcing the garbage collection and clearing the src
513 * attribute.
514 *
515 * @param {function()} onSuccess Success callback.
516 * @param {function(opt_string)} onError Failure callback with an optional
517 *     error identifier.
518 * @private
519 */
520ImageUtil.ImageLoader.prototype.resetImage_ = function(onSuccess, onError) {
521  var clearSrc = function() {
522    this.image_.onload = onSuccess;
523    this.image_.onerror = onSuccess;
524    this.image_.src = '';
525  }.bind(this);
526
527  var emptyImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAA' +
528      'AAABAAEAAAICTAEAOw==';
529
530  if (this.image_.src !== emptyImage) {
531    // Load an empty image, then clear src.
532    this.image_.onload = clearSrc;
533    this.image_.onerror = onError.bind(this, 'GALLERY_IMAGE_ERROR');
534    this.image_.src = emptyImage;
535  } else {
536    // Empty image already loaded, so clear src immediately.
537    clearSrc();
538  }
539};
540
541/**
542 * @return {boolean} True if an image is loading.
543 */
544ImageUtil.ImageLoader.prototype.isBusy = function() {
545  return !!this.callback_;
546};
547
548/**
549 * @param {Entry} entry Image entry.
550 * @return {boolean} True if loader loads this image.
551 */
552ImageUtil.ImageLoader.prototype.isLoading = function(entry) {
553  return this.isBusy() && util.isSameEntry(this.entry_, entry);
554};
555
556/**
557 * @param {function} callback To be called when the image loaded.
558 */
559ImageUtil.ImageLoader.prototype.setCallback = function(callback) {
560  this.callback_ = callback;
561};
562
563/**
564 * Stops loading image.
565 */
566ImageUtil.ImageLoader.prototype.cancel = function() {
567  if (!this.callback_) return;
568  this.callback_ = null;
569  if (this.timeout_) {
570    clearTimeout(this.timeout_);
571    this.timeout_ = null;
572  }
573  if (this.image_) {
574    this.image_.onload = function() {};
575    this.image_.onerror = function() {};
576    this.image_.src = '';
577  }
578  this.generation_++;  // Silence the transform fetcher if it is in progress.
579};
580
581/**
582 * @param {HTMLImageElement} image Image to be transformed.
583 * @param {Object} transform transformation description to apply to the image.
584 * @private
585 */
586ImageUtil.ImageLoader.prototype.convertImage_ = function(image, transform) {
587  var canvas = this.document_.createElement('canvas');
588
589  if (transform.rotate90 & 1) {  // Rotated +/-90deg, swap the dimensions.
590    canvas.width = image.height;
591    canvas.height = image.width;
592  } else {
593    canvas.width = image.width;
594    canvas.height = image.height;
595  }
596
597  var context = canvas.getContext('2d');
598  context.save();
599  context.translate(canvas.width / 2, canvas.height / 2);
600  context.rotate(transform.rotate90 * Math.PI / 2);
601  context.scale(transform.scaleX, transform.scaleY);
602
603  var stripCount = Math.ceil(image.width * image.height / (1 << 21));
604  var step = Math.max(16, Math.ceil(image.height / stripCount)) & 0xFFFFF0;
605
606  this.copyStrip_(context, image, 0, step);
607};
608
609/**
610 * @param {CanvasRenderingContext2D} context Context to draw.
611 * @param {HTMLImageElement} image Image to draw.
612 * @param {number} firstRow Number of the first pixel row to draw.
613 * @param {number} rowCount Count of pixel rows to draw.
614 * @private
615 */
616ImageUtil.ImageLoader.prototype.copyStrip_ = function(
617    context, image, firstRow, rowCount) {
618  var lastRow = Math.min(firstRow + rowCount, image.height);
619
620  context.drawImage(
621      image, 0, firstRow, image.width, lastRow - firstRow,
622      -image.width / 2, firstRow - image.height / 2,
623      image.width, lastRow - firstRow);
624
625  if (lastRow === image.height) {
626    context.restore();
627    if (this.entry_.toURL().substr(0, 5) !== 'data:') {  // Ignore data urls.
628      ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('LoadTime'));
629    }
630    try {
631      setTimeout(this.callback_, 0, context.canvas);
632    } catch (e) {
633      console.error(e);
634    }
635    this.callback_ = null;
636  } else {
637    var self = this;
638    this.timeout_ = setTimeout(
639        function() {
640          self.timeout_ = null;
641          self.copyStrip_(context, image, lastRow, rowCount);
642        }, 0);
643  }
644};
645
646/**
647 * @param {HTMLElement} element To remove children from.
648 */
649ImageUtil.removeChildren = function(element) {
650  element.textContent = '';
651};
652
653/**
654 * @param {string} name File name (with extension).
655 * @return {string} File name without extension.
656 */
657ImageUtil.getDisplayNameFromName = function(name) {
658  var index = name.lastIndexOf('.');
659  if (index !== -1)
660    return name.substr(0, index);
661  else
662    return name;
663};
664
665/**
666 * @param {string} name File name.
667 * @return {string} File extension.
668 */
669ImageUtil.getExtensionFromFullName = function(name) {
670  var index = name.lastIndexOf('.');
671  if (index !== -1)
672    return name.substring(index);
673  else
674    return '';
675};
676
677/**
678 * Metrics (from metrics.js) itnitialized by the File Manager from owner frame.
679 * @type {Object?}
680 */
681ImageUtil.metrics = null;
682
683/**
684 * @param {string} name Local name.
685 * @return {string} Full name.
686 */
687ImageUtil.getMetricName = function(name) {
688  return 'PhotoEditor.' + name;
689};
690
691/**
692 * Used for metrics reporting, keep in sync with the histogram description.
693 */
694ImageUtil.FILE_TYPES = ['jpg', 'png', 'gif', 'bmp', 'webp'];
695