scratch-paint/src/helper/bitmap.js

972 lines
37 KiB
JavaScript
Raw Normal View History

2018-04-06 16:53:09 -04:00
import paper from '@scratch/paper';
2018-06-28 00:21:01 -04:00
import {createCanvas, clearRaster, getRaster, hideGuideLayers, showGuideLayers} from './layer';
2018-06-19 14:20:06 -04:00
import {getGuideColor} from './guides';
2018-08-29 15:29:13 -04:00
import {clearSelection} from './selection';
import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, CENTER, MAX_WORKSPACE_BOUNDS} from './view';
import Formats from '../lib/format';
2020-10-13 20:59:07 -04:00
import log from '../log/log';
2018-04-06 16:53:09 -04:00
2018-04-20 10:57:10 -04:00
const forEachLinePoint = function (point1, point2, callback) {
// Bresenham line algorithm
let x1 = ~~point1.x;
const x2 = ~~point2.x;
let y1 = ~~point1.y;
const y2 = ~~point2.y;
2018-06-19 14:54:29 -04:00
const dx = Math.abs(x2 - x1);
const dy = Math.abs(y2 - y1);
const sx = (x1 < x2) ? 1 : -1;
const sy = (y1 < y2) ? 1 : -1;
let err = dx - dy;
2018-06-19 14:54:29 -04:00
callback(x1, y1);
while (x1 !== x2 || y1 !== y2) {
const e2 = err * 2;
if (e2 > -dy) {
2018-04-20 10:57:10 -04:00
err -= dy;
x1 += sx;
}
if (e2 < dx) {
2018-04-20 10:57:10 -04:00
err += dx;
y1 += sy;
}
callback(x1, y1);
}
};
2018-06-19 14:20:06 -04:00
/**
* @param {!number} a Coefficient in ax^2 + bx + c = 0
* @param {!number} b Coefficient in ax^2 + bx + c = 0
* @param {!number} c Coefficient in ax^2 + bx + c = 0
* @return {Array<number>} Array of 2 solutions, with the larger solution first
*/
const solveQuadratic_ = function (a, b, c) {
const soln1 = (-b + Math.sqrt((b * b) - (4 * a * c))) / 2 / a;
const soln2 = (-b - Math.sqrt((b * b) - (4 * a * c))) / 2 / a;
return soln1 > soln2 ? [soln1, soln2] : [soln2, soln1];
};
/**
* @param {!object} options drawing options
* @param {!number} options.centerX center of ellipse, x
* @param {!number} options.centerY center of ellipse, y
* @param {!number} options.radiusX major radius of ellipse
* @param {!number} options.radiusY minor radius of ellipse
* @param {!number} options.shearSlope slope of the sheared x axis
* @param {?boolean} options.isFilled true if isFilled
* @param {?function} options.drawFn The function called on each point in the outline, used only
* if isFilled is false.
2018-06-19 14:20:06 -04:00
* @param {!CanvasRenderingContext2D} context for drawing
* @return {boolean} true if anything was drawn, false if not
*/
const drawShearedEllipse_ = function (options, context) {
const centerX = ~~options.centerX;
const centerY = ~~options.centerY;
const radiusX = ~~Math.abs(options.radiusX) - .5;
const radiusY = ~~Math.abs(options.radiusY) - .5;
const shearSlope = options.shearSlope;
const isFilled = options.isFilled;
const drawFn = options.drawFn;
2018-06-19 14:20:06 -04:00
if (shearSlope === Infinity || radiusX < 1 || radiusY < 1) {
return false;
}
2018-06-19 14:20:06 -04:00
// A, B, and C represent Ax^2 + Bxy + Cy^2 = 1 coefficients in a skewed ellipse formula
const A = (1 / radiusX / radiusX) + (shearSlope * shearSlope / radiusY / radiusY);
const B = -2 * shearSlope / radiusY / radiusY;
const C = 1 / radiusY / radiusY;
// Line with slope1 intersects the ellipse where its derivative is 1
const slope1 = ((-2 * A) - B) / ((2 * C) + B);
// Line with slope2 intersects the ellipse where its derivative is -1
const slope2 = (-(2 * A) + B) / (-(2 * C) + B);
const verticalStepsFirst = slope1 > slope2;
2018-06-19 14:20:06 -04:00
/**
* Vertical stepping portion of ellipse drawing algorithm
* @param {!number} startY y to start drawing from
* @param {!function} conditionFn function which should become true when we should stop stepping
* @return {object} last point drawn to the canvas, or null if no points drawn
*/
const drawEllipseStepVertical_ = function (startY, conditionFn) {
// Points on the ellipse
let y = startY;
let x = solveQuadratic_(A, B * y, (C * y * y) - 1);
// last pixel position at which a draw was performed
let pY;
let pX1;
let pX2;
while (conditionFn(x[0], y)) {
pY = Math.floor(y);
pX1 = Math.floor(x[0]);
pX2 = Math.floor(x[1]);
if (isFilled) {
context.fillRect(centerX - pX1 - 1, centerY + pY, pX1 - pX2 + 1, 1);
context.fillRect(centerX + pX2, centerY - pY - 1, pX1 - pX2 + 1, 1);
} else {
drawFn(centerX - pX1 - 1, centerY + pY);
drawFn(centerX + pX1, centerY - pY - 1);
2018-06-19 14:20:06 -04:00
}
y--;
2018-06-19 14:20:06 -04:00
x = solveQuadratic_(A, B * y, (C * y * y) - 1);
}
return pX1 || pY ? {x: pX1, y: pY} : null;
};
/**
* Horizontal stepping portion of ellipse drawing algorithm
* @param {!number} startX x to start drawing from
* @param {!function} conditionFn function which should become false when we should stop stepping
* @return {object} last point drawn to the canvas, or null if no points drawn
*/
const drawEllipseStepHorizontal_ = function (startX, conditionFn) {
// Points on the ellipse
let x = startX;
let y = solveQuadratic_(C, B * x, (A * x * x) - 1);
// last pixel position at which a draw was performed
let pX;
let pY1;
let pY2;
while (conditionFn(x, y[0])) {
pX = Math.floor(x);
pY1 = Math.floor(y[0]);
pY2 = Math.floor(y[1]);
if (isFilled) {
context.fillRect(centerX - pX - 1, centerY + pY2, 1, pY1 - pY2 + 1);
context.fillRect(centerX + pX, centerY - pY1 - 1, 1, pY1 - pY2 + 1);
} else {
drawFn(centerX - pX - 1, centerY + pY1);
drawFn(centerX + pX, centerY - pY1 - 1);
2018-06-19 14:20:06 -04:00
}
x++;
y = solveQuadratic_(C, B * x, (A * x * x) - 1);
}
2018-06-19 14:20:06 -04:00
return pX || pY1 ? {x: pX, y: pY1} : null;
};
2018-06-19 14:20:06 -04:00
// Last point drawn
let lastPoint;
if (verticalStepsFirst) {
let forwardLeaning = false;
if (slope1 > 0) forwardLeaning = true;
// step vertically
lastPoint = drawEllipseStepVertical_(
forwardLeaning ? -radiusY : radiusY,
(x, y) => {
if (x === 0 && y > 0) return true;
if (x === 0 && y < 0) return false;
return y / x > slope1;
}
);
// step horizontally while slope is flat
lastPoint = drawEllipseStepHorizontal_(
lastPoint ? -lastPoint.x + .5 : .5,
(x, y) => y / x > slope2
) || {x: -lastPoint.x - .5, y: -lastPoint.y - .5};
// step vertically until back to start
drawEllipseStepVertical_(
lastPoint.y - .5,
(x, y) => {
if (forwardLeaning) return y > -radiusY;
return y > radiusY;
}
);
} else {
// step horizontally forward
lastPoint = drawEllipseStepHorizontal_(
.5,
(x, y) => y / x > slope2
);
// step vertically while slope is steep
lastPoint = drawEllipseStepVertical_(
lastPoint ? lastPoint.y - .5 : radiusY,
(x, y) => {
if (x === 0 && y > 0) return true;
if (x === 0 && y < 0) return false;
return y / x > slope1;
}
) || lastPoint;
// step horizontally until back to start
drawEllipseStepHorizontal_(
-lastPoint.x + .5,
x => x < 0
);
}
2018-06-19 14:20:06 -04:00
return true;
};
2018-04-26 14:54:44 -04:00
/**
* @param {!number} size The diameter of the brush
* @param {!string} color The css color of the brush
2018-06-14 10:35:02 -04:00
* @param {?boolean} isEraser True if we want the brush mark for the eraser
2018-04-26 14:54:44 -04:00
* @return {HTMLCanvasElement} a canvas with the brush mark printed on it
*/
2018-06-14 10:35:02 -04:00
const getBrushMark = function (size, color, isEraser) {
2018-04-26 14:54:44 -04:00
size = ~~size;
const canvas = document.createElement('canvas');
const roundedUpRadius = Math.ceil(size / 2);
canvas.width = roundedUpRadius * 2;
canvas.height = roundedUpRadius * 2;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
2018-06-14 10:35:02 -04:00
context.fillStyle = isEraser ? 'white' : color;
2018-04-26 14:54:44 -04:00
// Small squares for pixel artists
if (size <= 5) {
2018-06-19 14:20:06 -04:00
let offset = 0;
if (size % 2) offset = 1;
if (isEraser) {
context.fillStyle = getGuideColor();
context.fillRect(offset, offset, size, size);
context.fillStyle = 'white';
context.fillRect(offset + 1, offset + 1, size - 2, size - 2);
2018-04-26 14:54:44 -04:00
} else {
2018-06-19 14:20:06 -04:00
context.fillRect(offset, offset, size, size);
2018-04-26 14:54:44 -04:00
}
} else {
2018-06-19 14:20:06 -04:00
drawShearedEllipse_({
centerX: size / 2,
centerY: size / 2,
radiusX: size / 2,
radiusY: size / 2,
shearSlope: 0,
isFilled: true
}, context);
if (isEraser) {
// Add outline
context.fillStyle = getGuideColor();
drawShearedEllipse_({
centerX: size / 2,
centerY: size / 2,
radiusX: size / 2,
radiusY: size / 2,
shearSlope: 0,
isFilled: false,
drawFn: (x, y) => context.fillRect(x, y, 1, 1)
2018-06-19 14:20:06 -04:00
}, context);
}
2018-04-26 14:54:44 -04:00
}
return canvas;
};
/**
* Draw an ellipse, given the original axis-aligned radii and
* an affine transformation. Returns false if the ellipse could
* not be drawn; for instance, the matrix is non-invertible.
*
* @param {!options} options Parameters for the ellipse
* @param {!paper.Point} options.position Center of ellipse
* @param {!number} options.radiusX x-aligned radius of ellipse
* @param {!number} options.radiusY y-aligned radius of ellipse
* @param {!paper.Matrix} options.matrix affine transformation matrix
* @param {?boolean} options.isFilled true if isFilled
* @param {?number} options.thickness Thickness of outline, used only if isFilled is false.
* @param {!CanvasRenderingContext2D} context for drawing
* @return {boolean} true if anything was drawn, false if not
*/
const drawEllipse = function (options, context) {
const positionX = options.position.x;
const positionY = options.position.y;
const radiusX = options.radiusX;
const radiusY = options.radiusY;
const matrix = options.matrix;
const isFilled = options.isFilled;
const thickness = options.thickness;
let drawFn = null;
if (!matrix.isInvertible()) return false;
const inverse = matrix.clone().invert();
2020-06-09 15:02:36 -04:00
const isGradient = context.fillStyle instanceof CanvasGradient;
// If drawing a gradient, we need to draw the shape onto a temporary canvas, then draw the gradient atop that canvas
// only where the shape appears. drawShearedEllipse draws some pixels twice, which would be a problem if the
// gradient fades to transparent as those pixels would end up looking more opaque. Instead, mask in the gradient.
// https://github.com/LLK/scratch-paint/issues/1152
// Outlines are drawn as a series of brush mark images and as such can't be drawn as gradients in the first place.
let origContext;
let tmpCanvas;
const {width: canvasWidth, height: canvasHeight} = context.canvas;
if (isGradient) {
tmpCanvas = createCanvas(canvasWidth, canvasHeight);
origContext = context;
context = tmpCanvas.getContext('2d');
}
if (!isFilled) {
2020-06-09 15:02:36 -04:00
const brushMark = getBrushMark(thickness, isGradient ? 'black' : context.fillStyle);
const roundedUpRadius = Math.ceil(thickness / 2);
drawFn = (x, y) => {
context.drawImage(brushMark, ~~x - roundedUpRadius, ~~y - roundedUpRadius);
};
}
// Calculate the ellipse formula
// A, B, and C represent Ax^2 + Bxy + Cy^2 = 1 coefficients in a transformed ellipse formula
const A = (inverse.a * inverse.a / radiusX / radiusX) + (inverse.b * inverse.b / radiusY / radiusY);
const B = (2 * inverse.a * inverse.c / radiusX / radiusX) + (2 * inverse.b * inverse.d / radiusY / radiusY);
const C = (inverse.c * inverse.c / radiusX / radiusX) + (inverse.d * inverse.d / radiusY / radiusY);
// Convert to a sheared ellipse formula. All ellipses are equivalent to some sheared axis-aligned ellipse.
// radiusA, radiusB, and slope are parameters of a skewed ellipse with the above formula
const radiusB = 1 / Math.sqrt(C);
const radiusA = Math.sqrt(-4 * C / ((B * B) - (4 * A * C)));
const slope = B / 2 / C;
2020-06-09 15:02:36 -04:00
const wasDrawn = drawShearedEllipse_({
centerX: positionX,
centerY: positionY,
radiusX: radiusA,
radiusY: radiusB,
shearSlope: slope,
isFilled: isFilled,
drawFn: drawFn
}, context);
2020-06-09 15:02:36 -04:00
// Mask in the gradient only where the shape was drawn, and draw it. Then draw the gradientified shape onto the
// original canvas normally.
if (isGradient && wasDrawn) {
context.globalCompositeOperation = 'source-in';
context.fillStyle = origContext.fillStyle;
context.fillRect(0, 0, canvasWidth, canvasHeight);
origContext.drawImage(tmpCanvas, 0, 0);
}
return wasDrawn;
};
2018-04-06 16:53:09 -04:00
const rowBlank_ = function (imageData, width, y) {
for (let x = 0; x < width; ++x) {
if (imageData.data[(y * width << 2) + (x << 2) + 3] !== 0) return false;
}
return true;
};
const columnBlank_ = function (imageData, width, x, top, bottom) {
for (let y = top; y < bottom; ++y) {
if (imageData.data[(y * width << 2) + (x << 2) + 3] !== 0) return false;
}
return true;
};
2020-05-16 03:41:39 -04:00
/**
* Get bounds around the contents of a raster, trimming transparent pixels from edges.
* Adapted from Tim Down's https://gist.github.com/timdown/021d9c8f2aabc7092df564996f5afbbf
* @param {paper.Raster} raster The raster to get the bounds around
* @param {paper.Rectangle} [rect] Optionally, an alternative bounding rectangle to limit the check to.
* @returns {paper.Rectangle} The bounds around the opaque area of the passed raster
* (or opaque within the passed rectangle)
*/
const getHitBounds = function (raster, rect) {
const bounds = rect || raster.bounds;
const width = bounds.width;
const imageData = raster.getImageData(bounds);
2018-04-06 16:53:09 -04:00
let top = 0;
let bottom = imageData.height;
let left = 0;
let right = imageData.width;
while (top < bottom && rowBlank_(imageData, width, top)) ++top;
while (bottom - 1 > top && rowBlank_(imageData, width, bottom - 1)) --bottom;
while (left < right && columnBlank_(imageData, width, left, top, bottom)) ++left;
while (right - 1 > left && columnBlank_(imageData, width, right - 1, top, bottom)) --right;
// Center an empty bitmap
if (top === bottom) {
top = bottom = imageData.height / 2;
}
if (left === right) {
left = right = imageData.width / 2;
}
2020-05-16 03:41:39 -04:00
return new paper.Rectangle(left + bounds.left, top + bounds.top, right - left, bottom - top);
};
const trim_ = function (raster) {
const hitBounds = getHitBounds(raster);
if (hitBounds.width && hitBounds.height) {
return raster.getSubRaster(getHitBounds(raster));
}
return null;
2018-04-06 16:53:09 -04:00
};
/**
* @param {boolean} shouldInsert True if the trimmed raster should be added to the active layer.
* @returns {paper.Raster} raster layer with whitespace trimmed from ends, or null if there is
* nothing on the raster layer.
*/
const getTrimmedRaster = function (shouldInsert) {
const trimmedRaster = trim_(getRaster());
if (!trimmedRaster) return null;
if (shouldInsert) {
paper.project.activeLayer.addChild(trimmedRaster);
} else {
trimmedRaster.remove();
}
return trimmedRaster;
};
2020-10-13 20:59:07 -04:00
const convertToBitmap = function (clearSelectedItems, onUpdateImage, optFontInlineFn) {
// @todo if the active layer contains only rasters, drawing them directly to the raster layer
// would be more efficient.
2018-08-29 15:29:13 -04:00
clearSelection(clearSelectedItems);
// Export svg
const guideLayers = hideGuideLayers(true /* includeRaster */);
2018-10-29 11:01:53 -04:00
const bounds = paper.project.activeLayer.drawnBounds;
const svg = paper.project.exportSVG({
bounds: 'content',
matrix: new paper.Matrix().translate(-bounds.x, -bounds.y)
});
showGuideLayers(guideLayers);
// Get rid of anti-aliasing
2018-06-19 14:54:29 -04:00
// @todo get crisp text https://github.com/LLK/scratch-paint/issues/508
svg.setAttribute('shape-rendering', 'crispEdges');
let svgString = (new XMLSerializer()).serializeToString(svg);
2020-10-13 20:59:07 -04:00
if (optFontInlineFn) {
svgString = optFontInlineFn(svgString);
} else {
log.error('Fonts may be converted to bitmap incorrectly if fontInlineFn prop is not set on PaintEditor.');
}
// Put anti-aliased SVG into image, and dump image back into canvas
const img = new Image();
img.onload = () => {
if (img.width && img.height) {
getRaster().drawImage(
img,
new paper.Point(Math.floor(bounds.topLeft.x), Math.floor(bounds.topLeft.y)));
}
for (let i = paper.project.activeLayer.children.length - 1; i >= 0; i--) {
const item = paper.project.activeLayer.children[i];
if (item.clipMask === false) {
item.remove();
} else {
// Resize mask for bitmap bounds
item.size.height = ART_BOARD_HEIGHT;
item.size.width = ART_BOARD_WIDTH;
item.setPosition(CENTER);
}
}
onUpdateImage(false /* skipSnapshot */, Formats.BITMAP /* formatOverride */);
};
img.onerror = () => {
// Fallback if browser does not support SVG data URIs in images.
// The problem with rasterize is that it will anti-alias.
const raster = paper.project.activeLayer.rasterize(72, false /* insert */);
raster.onLoad = () => {
if (raster.canvas.width && raster.canvas.height) {
getRaster().drawImage(raster.canvas, raster.bounds.topLeft);
}
paper.project.activeLayer.removeChildren();
onUpdateImage(false /* skipSnapshot */, Formats.BITMAP /* formatOverride */);
};
};
// Hash tags will break image loading without being encoded first
img.src = `data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`;
};
const convertToVector = function (clearSelectedItems, onUpdateImage) {
2018-08-29 15:29:13 -04:00
clearSelection(clearSelectedItems);
for (const item of paper.project.activeLayer.children) {
if (item.clipMask === true) {
// Resize mask for vector bounds
item.size.height = MAX_WORKSPACE_BOUNDS.height;
item.size.width = MAX_WORKSPACE_BOUNDS.width;
item.setPosition(CENTER);
}
}
getTrimmedRaster(true /* shouldInsert */);
clearRaster();
onUpdateImage(false /* skipSnapshot */, Formats.VECTOR /* formatOverride */);
};
2018-06-11 11:48:35 -04:00
const getColor_ = function (x, y, context) {
return context.getImageData(x, y, 1, 1).data;
};
const matchesColor_ = function (x, y, imageData, oldColor) {
const index = ((y * imageData.width) + x) * 4;
return (
imageData.data[index + 0] === oldColor[0] &&
imageData.data[index + 1] === oldColor[1] &&
imageData.data[index + 2] === oldColor[2] &&
imageData.data[index + 3 ] === oldColor[3]
);
};
const colorPixel_ = function (x, y, imageData, newColor) {
const index = ((y * imageData.width) + x) * 4;
imageData.data[index + 0] = newColor[0];
imageData.data[index + 1] = newColor[1];
imageData.data[index + 2] = newColor[2];
imageData.data[index + 3] = newColor[3];
};
/**
* Flood fill beginning at the given point.
* Based on http://www.williammalone.com/articles/html5-canvas-javascript-paint-bucket-tool/
*
* @param {!int} x The x coordinate on the context at which to begin
* @param {!int} y The y coordinate on the context at which to begin
2018-07-17 17:21:02 -04:00
* @param {!ImageData} sourceImageData The image data to sample from. This is edited by the function.
* @param {!ImageData} destImageData The image data to edit. May match sourceImageData. Should match
* size of sourceImageData.
2018-06-11 11:48:35 -04:00
* @param {!Array<number>} newColor The color to replace with. A length 4 array [r, g, b, a].
* @param {!Array<number>} oldColor The color to replace. A length 4 array [r, g, b, a].
* This must be different from newColor.
* @param {!Array<Array<int>>} stack The stack of pixels we need to look at
*/
2018-07-17 17:21:02 -04:00
const floodFillInternal_ = function (x, y, sourceImageData, destImageData, newColor, oldColor, stack) {
while (y > 0 && matchesColor_(x, y - 1, sourceImageData, oldColor)) {
2018-06-11 11:48:35 -04:00
y--;
}
let lastLeftMatchedColor = false;
let lastRightMatchedColor = false;
2018-07-17 17:21:02 -04:00
for (; y < sourceImageData.height; y++) {
if (!matchesColor_(x, y, sourceImageData, oldColor)) break;
colorPixel_(x, y, sourceImageData, newColor);
colorPixel_(x, y, destImageData, newColor);
2018-06-11 11:48:35 -04:00
if (x > 0) {
2018-07-17 17:21:02 -04:00
if (matchesColor_(x - 1, y, sourceImageData, oldColor)) {
2018-06-11 11:48:35 -04:00
if (!lastLeftMatchedColor) {
stack.push([x - 1, y]);
lastLeftMatchedColor = true;
}
} else {
lastLeftMatchedColor = false;
}
}
2018-07-17 17:21:02 -04:00
if (x < sourceImageData.width - 1) {
if (matchesColor_(x + 1, y, sourceImageData, oldColor)) {
2018-06-11 11:48:35 -04:00
if (!lastRightMatchedColor) {
stack.push([x + 1, y]);
lastRightMatchedColor = true;
}
} else {
lastRightMatchedColor = false;
}
}
}
};
2018-06-14 11:44:11 -04:00
/**
* Given a fill style string, get the color
* @param {string} fillStyleString the fill style
* @return {Array<int>} Color, a length 4 array
*/
const fillStyleToColor_ = function (fillStyleString) {
const tmpCanvas = document.createElement('canvas');
tmpCanvas.width = 1;
tmpCanvas.height = 1;
const context = tmpCanvas.getContext('2d');
context.fillStyle = fillStyleString;
context.fillRect(0, 0, 1, 1);
return context.getImageData(0, 0, 1, 1).data;
};
2018-06-11 11:48:35 -04:00
/**
* Flood fill beginning at the given point
2018-06-14 11:44:11 -04:00
* @param {!number} x The x coordinate on the context at which to begin
* @param {!number} y The y coordinate on the context at which to begin
* @param {!string} color A color string, which would go into context.fillStyle
2018-07-17 17:21:02 -04:00
* @param {!HTMLCanvas2DContext} sourceContext The context from which to sample to determine where to flood fill
* @param {!HTMLCanvas2DContext} destContext The context to which to draw. May match sourceContext. Should match
* the size of sourceContext.
2018-06-14 11:44:11 -04:00
* @return {boolean} True if image changed, false otherwise
2018-06-11 11:48:35 -04:00
*/
2018-07-17 17:21:02 -04:00
const floodFill = function (x, y, color, sourceContext, destContext) {
2018-06-14 11:44:11 -04:00
x = ~~x;
y = ~~y;
const newColor = fillStyleToColor_(color);
2018-07-17 17:21:02 -04:00
const oldColor = getColor_(x, y, sourceContext);
const sourceImageData = sourceContext.getImageData(0, 0, sourceContext.canvas.width, sourceContext.canvas.height);
let destImageData = sourceImageData;
if (destContext !== sourceContext) {
destImageData = new ImageData(sourceContext.canvas.width, sourceContext.canvas.height);
}
2018-06-14 11:44:11 -04:00
if (oldColor[0] === newColor[0] &&
oldColor[1] === newColor[1] &&
oldColor[2] === newColor[2] &&
oldColor[3] === newColor[3]) { // no-op
return false;
2018-06-11 11:48:35 -04:00
}
const stack = [[x, y]];
while (stack.length) {
const pop = stack.pop();
2018-07-17 17:21:02 -04:00
floodFillInternal_(pop[0], pop[1], sourceImageData, destImageData, newColor, oldColor, stack);
2018-06-11 11:48:35 -04:00
}
2018-07-17 17:21:02 -04:00
destContext.putImageData(destImageData, 0, 0);
2018-06-14 11:44:11 -04:00
return true;
};
/**
* Replace all instances of the color at the given point
* @param {!number} x The x coordinate on the context of the start color
* @param {!number} y The y coordinate on the context of the start color
* @param {!string} color A color string, which would go into context.fillStyle
2018-07-17 17:21:02 -04:00
* @param {!HTMLCanvas2DContext} sourceContext The context from which to sample to determine where to flood fill
* @param {!HTMLCanvas2DContext} destContext The context to which to draw. May match sourceContext. Should match
2018-06-14 11:44:11 -04:00
* @return {boolean} True if image changed, false otherwise
*/
2018-07-17 17:21:02 -04:00
const floodFillAll = function (x, y, color, sourceContext, destContext) {
2018-06-14 11:44:11 -04:00
x = ~~x;
y = ~~y;
const newColor = fillStyleToColor_(color);
2018-07-17 17:21:02 -04:00
const oldColor = getColor_(x, y, sourceContext);
const sourceImageData = sourceContext.getImageData(0, 0, sourceContext.canvas.width, sourceContext.canvas.height);
let destImageData = sourceImageData;
if (destContext !== sourceContext) {
destImageData = new ImageData(sourceContext.canvas.width, sourceContext.canvas.height);
}
2018-06-14 11:44:11 -04:00
if (oldColor[0] === newColor[0] &&
oldColor[1] === newColor[1] &&
oldColor[2] === newColor[2] &&
oldColor[3] === newColor[3]) { // no-op
return false;
}
2018-07-17 17:21:02 -04:00
for (let i = 0; i < sourceImageData.width; i++) {
for (let j = 0; j < sourceImageData.height; j++) {
if (matchesColor_(i, j, sourceImageData, oldColor)) {
colorPixel_(i, j, destImageData, newColor);
2018-06-14 11:44:11 -04:00
}
}
}
2018-07-17 17:21:02 -04:00
destContext.putImageData(destImageData, 0, 0);
2018-06-14 11:44:11 -04:00
return true;
2018-06-11 11:48:35 -04:00
};
/**
* @param {!paper.Shape.Rectangle} rect The rectangle to draw to the canvas
* @param {!HTMLCanvas2DContext} context The context in which to draw
*/
2018-06-25 16:49:28 -04:00
const fillRect = function (rect, context) {
2018-06-11 11:48:35 -04:00
// No rotation component to matrix
if (rect.matrix.b === 0 && rect.matrix.c === 0) {
const width = rect.size.width * rect.matrix.a;
const height = rect.size.height * rect.matrix.d;
context.fillRect(
2018-06-21 10:22:24 -04:00
Math.round(rect.matrix.tx - (width / 2)),
Math.round(rect.matrix.ty - (height / 2)),
Math.round(width),
Math.round(height)
);
2018-06-11 11:48:35 -04:00
return;
}
const startPoint = rect.matrix.transform(new paper.Point(-rect.size.width / 2, -rect.size.height / 2));
const widthPoint = rect.matrix.transform(new paper.Point(rect.size.width / 2, -rect.size.height / 2));
const heightPoint = rect.matrix.transform(new paper.Point(-rect.size.width / 2, rect.size.height / 2));
const endPoint = rect.matrix.transform(new paper.Point(rect.size.width / 2, rect.size.height / 2));
const center = rect.matrix.transform(new paper.Point());
2018-06-25 16:49:28 -04:00
const points = [startPoint, widthPoint, heightPoint, endPoint].sort((a, b) => a.x - b.x);
const solveY = (point1, point2, x) => {
if (point2.x === point1.x) return center.x > point1.x ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
return ((point2.y - point1.y) / (point2.x - point1.x) * (x - point1.x)) + point1.y;
};
for (let x = Math.round(points[0].x); x < Math.round(points[3].x); x++) {
const ys = [
solveY(startPoint, widthPoint, x + .5),
solveY(startPoint, heightPoint, x + .5),
solveY(endPoint, widthPoint, x + .5),
solveY(endPoint, heightPoint, x + .5)
].sort((a, b) => a - b);
context.fillRect(x, Math.round(ys[1]), 1, Math.max(1, Math.round(ys[2]) - Math.round(ys[1])));
}
2018-06-11 11:48:35 -04:00
};
/**
* @param {!paper.Shape.Rectangle} rect The rectangle to draw to the canvas
* @param {!number} thickness The thickness of the outline
* @param {!HTMLCanvas2DContext} context The context in which to draw
*/
const outlineRect = function (rect, thickness, context) {
const brushMark = getBrushMark(thickness, context.fillStyle);
const roundedUpRadius = Math.ceil(thickness / 2);
const drawFn = (x, y) => {
context.drawImage(brushMark, ~~x - roundedUpRadius, ~~y - roundedUpRadius);
};
2020-06-09 15:02:36 -04:00
const isGradient = context.fillStyle instanceof CanvasGradient;
// If drawing a gradient, we need to draw the shape onto a temporary canvas, then draw the gradient atop that canvas
// only where the shape appears. Outlines are drawn as a series of brush mark images and as such can't be drawn as
// gradients.
let origContext;
let tmpCanvas;
const {width: canvasWidth, height: canvasHeight} = context.canvas;
if (isGradient) {
tmpCanvas = createCanvas(canvasWidth, canvasHeight);
origContext = context;
context = tmpCanvas.getContext('2d');
}
const startPoint = rect.matrix.transform(new paper.Point(-rect.size.width / 2, -rect.size.height / 2));
const widthPoint = rect.matrix.transform(new paper.Point(rect.size.width / 2, -rect.size.height / 2));
const heightPoint = rect.matrix.transform(new paper.Point(-rect.size.width / 2, rect.size.height / 2));
const endPoint = rect.matrix.transform(new paper.Point(rect.size.width / 2, rect.size.height / 2));
forEachLinePoint(startPoint, widthPoint, drawFn);
forEachLinePoint(startPoint, heightPoint, drawFn);
forEachLinePoint(endPoint, widthPoint, drawFn);
forEachLinePoint(endPoint, heightPoint, drawFn);
2020-06-09 15:02:36 -04:00
// Mask in the gradient only where the shape was drawn, and draw it. Then draw the gradientified shape onto the
// original canvas normally.
if (isGradient) {
context.globalCompositeOperation = 'source-in';
context.fillStyle = origContext.fillStyle;
context.fillRect(0, 0, canvasWidth, canvasHeight);
origContext.drawImage(tmpCanvas, 0, 0);
}
};
2018-06-28 01:54:05 -04:00
const flipBitmapHorizontal = function (canvas) {
const tmpCanvas = createCanvas(canvas.width, canvas.height);
2018-06-28 00:21:01 -04:00
const context = tmpCanvas.getContext('2d');
context.save();
context.scale(-1, 1);
2018-06-28 01:54:05 -04:00
context.drawImage(canvas, 0, 0, -tmpCanvas.width, tmpCanvas.height);
2018-06-28 00:21:01 -04:00
context.restore();
return tmpCanvas;
};
2018-06-28 01:54:05 -04:00
const flipBitmapVertical = function (canvas) {
const tmpCanvas = createCanvas(canvas.width, canvas.height);
2018-06-28 00:21:01 -04:00
const context = tmpCanvas.getContext('2d');
context.save();
context.scale(1, -1);
2018-06-28 01:54:05 -04:00
context.drawImage(canvas, 0, 0, tmpCanvas.width, -tmpCanvas.height);
2018-06-28 00:21:01 -04:00
context.restore();
return tmpCanvas;
};
2018-06-28 01:54:05 -04:00
const scaleBitmap = function (canvas, scale) {
let tmpCanvas = createCanvas(Math.round(canvas.width * Math.abs(scale.x)), canvas.height);
if (scale.x < 0) {
canvas = flipBitmapHorizontal(canvas);
}
tmpCanvas.getContext('2d').drawImage(canvas, 0, 0, tmpCanvas.width, tmpCanvas.height);
canvas = tmpCanvas;
tmpCanvas = createCanvas(canvas.width, Math.round(canvas.height * Math.abs(scale.y)));
if (scale.y < 0) {
canvas = flipBitmapVertical(canvas);
}
tmpCanvas.getContext('2d').drawImage(canvas, 0, 0, tmpCanvas.width, tmpCanvas.height);
return tmpCanvas;
};
2018-07-25 19:07:35 -04:00
/**
* Given a raster, take the scale on the transform and apply it to the raster's canvas, then remove
* the scale from the item's transform matrix. Do this only if scale.x or scale.y is less than 1.
* @param {paper.Raster} item raster to change
*/
const maybeApplyScaleToCanvas_ = function (item) {
// context.drawImage will anti-alias the image if both width and height are reduced.
// However, it will preserve pixel colors if only one or the other is reduced, and
// imageSmoothingEnabled is set to false. Therefore, we can avoid aliasing by scaling
// down images in a 2 step process.
const decomposed = item.matrix.decompose(); // Decomposition order: translate, rotate, scale, skew
if (Math.abs(decomposed.scaling.x) < 1 && Math.abs(decomposed.scaling.y) < 1 &&
decomposed.scaling.x !== 0 && decomposed.scaling.y !== 0) {
item.canvas = scaleBitmap(item.canvas, decomposed.scaling);
if (item.data && item.data.expanded) {
item.data.expanded.canvas = scaleBitmap(item.data.expanded.canvas, decomposed.scaling);
}
// Remove the scale from the item's matrix
item.matrix.append(
new paper.Matrix().scale(new paper.Point(1 / decomposed.scaling.x, 1 / decomposed.scaling.y)));
}
};
/**
* Given a raster, apply its transformation matrix to its canvas. Call maybeApplyScaleToCanvas_ first
* to avoid introducing anti-aliasing to scaled-down rasters.
* @param {paper.Raster} item raster to resolve transform of
* @param {paper.Raster} destination raster to draw selection to
*/
const commitArbitraryTransformation_ = function (item, destination) {
// Create a canvas to perform masking
const tmpCanvas = createCanvas();
const context = tmpCanvas.getContext('2d');
// Draw mask
const rect = new paper.Shape.Rectangle(new paper.Point(), item.size);
rect.matrix = item.matrix;
fillRect(rect, context);
rect.remove();
context.globalCompositeOperation = 'source-in';
// Draw image onto mask
const m = item.matrix;
context.transform(m.a, m.b, m.c, m.d, m.tx, m.ty);
let canvas = item.canvas;
if (item.data && item.data.expanded) {
canvas = item.data.expanded.canvas;
}
context.transform(1, 0, 0, 1, -canvas.width / 2, -canvas.height / 2);
context.drawImage(canvas, 0, 0);
// Draw temp canvas onto raster layer
destination.drawImage(tmpCanvas, new paper.Point());
};
/**
* Given a raster item, take its transform matrix and apply it to its canvas. Try to avoid
* introducing anti-aliasing.
* @param {paper.Raster} selection raster to resolve transform of
* @param {paper.Raster} bitmap raster to draw selection to
*/
const commitSelectionToBitmap = function (selection, bitmap) {
2018-08-03 13:10:45 -04:00
if (!selection.matrix.isInvertible()) {
return;
}
2018-07-25 19:07:35 -04:00
maybeApplyScaleToCanvas_(selection);
commitArbitraryTransformation_(selection, bitmap);
};
2020-06-09 15:02:36 -04:00
/**
* Converts a Paper.js color style (an item's fillColor or strokeColor) into a canvas-applicable color style.
* Note that a "color style" as applied to an item is different from a plain paper.Color or paper.Gradient.
* For instance, a gradient "color style" has origin and destination points whereas an unattached paper.Gradient
* does not.
* @param {paper.Color} color The color to convert to a canvas color/gradient
* @param {CanvasRenderingContext2D} context The rendering context on which the style will be used
* @returns {string|CanvasGradient} The canvas fill/stroke style.
*/
const _paperColorToCanvasStyle = function (color, context) {
if (!color) return null;
if (color.type === 'gradient') {
let canvasGradient;
const {origin, destination} = color;
if (color.gradient.radial) {
// Adapted from:
// https://github.com/paperjs/paper.js/blob/b081fd72c72cd61331313c3961edb48f3dfaffbd/src/style/Color.js#L926-L935
let {highlight} = color;
const start = highlight || origin;
const radius = destination.getDistance(origin);
if (highlight) {
const vector = highlight.subtract(origin);
if (vector.getLength() > radius) {
// Paper ¯\_(ツ)_/¯
highlight = origin.add(vector.normalize(radius - 0.1));
}
}
canvasGradient = context.createRadialGradient(
start.x, start.y,
0,
origin.x, origin.y,
radius
);
} else {
canvasGradient = context.createLinearGradient(
origin.x, origin.y,
destination.x, destination.y
);
}
const {stops} = color.gradient;
// Adapted from:
// https://github.com/paperjs/paper.js/blob/b081fd72c72cd61331313c3961edb48f3dfaffbd/src/style/Color.js#L940-L950
for (let i = 0, len = stops.length; i < len; i++) {
const stop = stops[i];
const offset = stop.offset;
canvasGradient.addColorStop(
offset || i / (len - 1),
stop.color.toCSS()
);
}
return canvasGradient;
}
return color.toCSS();
};
2018-09-12 18:17:45 -04:00
/**
* @param {paper.Shape.Ellipse} oval Vector oval to convert
* @param {paper.Raster} bitmap raster to draw selection
* @return {bool} true if the oval was drawn
*/
const commitOvalToBitmap = function (oval, bitmap) {
const radiusX = Math.abs(oval.size.width / 2);
const radiusY = Math.abs(oval.size.height / 2);
const context = bitmap.getContext('2d');
const filled = oval.strokeWidth === 0;
2020-06-09 15:02:36 -04:00
const canvasColor = _paperColorToCanvasStyle(filled ? oval.fillColor : oval.strokeColor, context);
// If the color is null (e.g. fully transparent/"no fill"), don't bother drawing anything
if (!canvasColor) return;
2020-06-09 15:02:36 -04:00
context.fillStyle = canvasColor;
2018-09-12 18:17:45 -04:00
const drew = drawEllipse({
position: oval.position,
radiusX,
radiusY,
matrix: oval.matrix,
isFilled: filled,
thickness: oval.strokeWidth / paper.view.zoom
}, context);
return drew;
};
/**
* @param {paper.Rectangle} rect Vector rectangle to convert
* @param {paper.Raster} bitmap raster to draw selection to
*/
const commitRectToBitmap = function (rect, bitmap) {
const tmpCanvas = createCanvas();
const context = tmpCanvas.getContext('2d');
const filled = rect.strokeWidth === 0;
2020-06-09 15:02:36 -04:00
const canvasColor = _paperColorToCanvasStyle(filled ? rect.fillColor : rect.strokeColor, context);
// If the color is null (e.g. fully transparent/"no fill"), don't bother drawing anything
if (!canvasColor) return;
2020-06-09 15:02:36 -04:00
context.fillStyle = canvasColor;
2018-09-12 18:17:45 -04:00
if (filled) {
fillRect(rect, context);
} else {
outlineRect(rect, rect.strokeWidth / paper.view.zoom, context);
}
bitmap.drawImage(tmpCanvas, new paper.Point());
};
2018-08-29 15:29:13 -04:00
const selectAllBitmap = function (clearSelectedItems) {
clearSelection(clearSelectedItems);
// Copy trimmed raster to active layer. If the raster layer was empty, nothing is selected.
const trimmedRaster = getTrimmedRaster(true /* shouldInsert */);
if (trimmedRaster) {
trimmedRaster.selected = true;
}
2018-08-29 15:29:13 -04:00
// Clear raster layer
clearRaster();
};
2018-04-06 16:53:09 -04:00
export {
2018-07-25 19:07:35 -04:00
commitSelectionToBitmap,
2018-09-12 18:17:45 -04:00
commitOvalToBitmap,
commitRectToBitmap,
convertToBitmap,
convertToVector,
2018-06-25 16:49:28 -04:00
fillRect,
outlineRect,
2018-06-14 11:44:11 -04:00
floodFill,
floodFillAll,
2018-04-26 14:54:44 -04:00
getBrushMark,
getHitBounds,
getTrimmedRaster,
2018-06-19 14:20:06 -04:00
drawEllipse,
2018-06-28 00:21:01 -04:00
forEachLinePoint,
flipBitmapHorizontal,
2018-06-28 01:54:05 -04:00
flipBitmapVertical,
2018-08-29 15:29:13 -04:00
scaleBitmap,
selectAllBitmap
2018-04-06 16:53:09 -04:00
};