简体   繁体   中英

“Undo” canvas transformations for writing text

When applying a transformation with canvas, the resulting text is also (obviously) transformed. Is there a way to prevent certain transformations, such as reflection, of affecting text?

For example, I set a global transformation matrix so the Y-axis points upwards, X-axis to the right, and the (0, 0) point is in the center of the screen (what you'd expect of a mathematical coordinate system).

However, this also makes the text upside-down.

 const size = 200; const canvas = document.getElementsByTagName('canvas')[0] canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, -1, size / 2, size / 2); const triangle = [ {x: -70, y: -70, label: 'A'}, {x: 70, y: -70, label: 'B'}, {x: 0, y: 70, label: 'C'}, ]; // draw lines ctx.beginPath(); ctx.strokeStyle = 'black'; ctx.moveTo(triangle[2].x, triangle[2].y); triangle.forEach(v => ctx.lineTo(vx, vy)); ctx.stroke(); ctx.closePath(); // draw labels ctx.textAlign = 'center'; ctx.font = '24px Arial'; triangle.forEach(v => ctx.fillText(v.label, vx, vy - 8));
 <canvas></canvas>

Is there a "smart" way to get the text in "correct" orientation, apart from manually resetting transformation matrices?

My solution is rotate the canvas and then draw the text.

ctx.scale(1,-1); // rotate the canvas
triangle.forEach(v => {
ctx.fillText(v.label, v.x, -v.y + 25); // draw with a bit adapt position
});

Hope that helps :)

 const size = 200; const canvas = document.getElementsByTagName('canvas')[0] canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, -1, size / 2, size / 2); const triangle = [ {x: -70, y: -70, label: 'A'}, {x: 70, y: -70, label: 'B'}, {x: 0, y: 70, label: 'C'}, ]; // draw lines ctx.beginPath(); ctx.strokeStyle = 'black'; ctx.moveTo(triangle[2].x, triangle[2].y); triangle.forEach(v => ctx.lineTo(vx, vy)); ctx.stroke(); ctx.closePath(); // draw labels ctx.textAlign = 'center'; ctx.font = '24px Arial'; ctx.scale(1,-1); triangle.forEach(v => { ctx.fillText(v.label, vx, -vy + 25); });
 <canvas></canvas>

To build off of Tai's answer, which is fantastic, you might want to consider the following:

 const size = 200; const canvas = document.getElementsByTagName('canvas')[0] canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); // Create a custom fillText funciton that flips the canvas, draws the text, and then flips it back ctx.fillText = function(text, x, y) { this.save(); // Save the current canvas state this.scale(1, -1); // Flip to draw the text this.fillText.dummyCtx.fillText.call(this, text, x, -y); // Draw the text, invert y to get coordinate right this.restore(); // Restore the initial canvas state } // Create a dummy canvas context to use as a source for the original fillText function ctx.fillText.dummyCtx = document.createElement('canvas').getContext('2d'); ctx.setTransform(1, 0, 0, -1, size / 2, size / 2); const triangle = [ {x: -70, y: -70, label: 'A'}, {x: 70, y: -70, label: 'B'}, {x: 0, y: 70, label: 'C'}, ]; // draw lines ctx.beginPath(); ctx.strokeStyle = 'black'; ctx.moveTo(triangle[2].x, triangle[2].y); triangle.forEach(v => ctx.lineTo(vx, vy)); ctx.stroke(); ctx.closePath(); // draw labels ctx.textAlign = 'center'; ctx.font = '24px Arial'; // For this particular example, multiplying x and y by small factors >1 offsets the labels from the triangle vertices triangle.forEach(v => ctx.fillText(v.label, 1.2*vx, 1.1*vy));

The above is useful if for your real application, you'll be going back and forth between drawing non-text objects and drawing text and don't want to have to remember to flip the canvas back and forth. (It's not a huge problem in the current example, because you draw the triangle and then draw all the text, so you only need one flip. But if you have in mind a different application that's more complex, that could be an annoyance.) In the above example, I've replaced the fillText method with a custom method that flips the canvas, draws the text, and then flips it back again so that you don't have to do it manually every time you want to draw text.

The result:

在此处输入图片说明

If you don't like overriding the default fillText , then obviously you can just create a method with a new name; that way you could also avoid creating the dummy context and just use this.fillText within your custom method.

EDIT: The above approach also works with arbitrary zoom and translation. scale(1, -1) simply reflects the canvas over the x-axis: after this transformation, a point that was at (x, y) will now be at (x, -y). This is true regardless of translation and zoom. If you want the text to remain a constant size regardless of zoom, then you just have to scale the font size by zoom. For example:

 <html> <body> <canvas id='canvas'></canvas> </body> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); var framesPerSec = 100; var msBetweenFrames = 1000/framesPerSec; ctx.font = '12px Arial'; function getRandomCamera() { return {x: ((Math.random() > 0.5) ? -1 : 1) * Math.random()*5, y: ((Math.random() > 0.5) ? -1 : 1) * Math.random()*5+5, zoom: Math.random()*20+0.1, }; } var camera = getRandomCamera(); moveCamera(); function moveCamera() { var newCamera = getRandomCamera(); var transitionFrames = Math.random()*500+100; var animationTime = transitionFrames*msBetweenFrames; var cameraSteps = { x: (newCamera.x-camera.x)/transitionFrames, y: (newCamera.y-camera.y)/transitionFrames, zoom: (newCamera.zoom-camera.zoom)/transitionFrames }; for (var t=0; t<animationTime; t+=msBetweenFrames) { window.setTimeout(updateCanvas, t); } window.setTimeout(moveCamera, animationTime); function updateCanvas() { camera.x += cameraSteps.x; camera.y += cameraSteps.y; camera.zoom += cameraSteps.zoom; redrawCanvas(); } } ctx.drawText = function(text, x, y) { this.save(); this.transform(1 / camera.zoom, 0, 0, -1 / camera.zoom, x, y); this.fillText(text, 0, 0); this.restore(); } function redrawCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(canvas.width / 2 - (camera.x * camera.zoom), canvas.height / 2 + (camera.y * camera.zoom)); ctx.scale(camera.zoom, -camera.zoom); for (var i = 0; i < 10; i++) { ctx.beginPath(); ctx.arc(5, i * 2, .5, 0, 2 * Math.PI); ctx.drawText(i, 7, i*2-0.5); ctx.fill(); } ctx.restore(); } </script> </html>

EDIT: Modified text scaling method based on suggestion by Blindman67. Also improved demo by making camera motion gradual.

I'd go with an approach that stores the "state" of your drawing without the actual pixels, and defines a draw method that can render this state at any point.

You'll have to implement your own scale and translate methods for your points, but I think it's worth it in the end.

So, in bullets:

  • Store a list of "things to draw" (points with labels)
  • Expose scale and translate methods that modify these "things"
  • Expose a draw method that renders these "things"

As an example, I've created a class called Figure that shows a 1.0 implementation of these features. I create a new instance that references a canvas. I then add points to it by passing an x , y and a label . scale and transform update these points' x and y properties. draw loops through the points to a) draw the "dot", and b) draw the label.

 const Figure = function(canvas) { const ctx = canvas.getContext('2d'); const origin = { x: canvas.width / 2, y: canvas.height / 2 }; const shift = p => Object.assign(p, { x: origin.x + px, y: origin.y - py }); let points = []; this.addPoint = (x, y, label) => { points = points.concat({ x, y, label }); } this.translate = (tx, ty) => { points = points.map( p => Object.assign(p, { x: px + tx, y: py + ty }) ); }; this.scale = (sx, sy) => { points = points.map( p => Object.assign(p, { x: px * sx, y: py * sy }) ); }; this.draw = function() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); const sPoints = points.map(shift); sPoints.forEach(p => drawDot(ctx, 5, px, py)); sPoints.forEach(p => drawLabel(ctx, p.label, px + 5, py)); ctx.fill(); } } const init = () => { const canvas = document.getElementById('canvas'); const fig = new Figure(canvas); // Generate some test data for (let i = 0, labels = "ABCD"; i < labels.length; i += 1) { fig.addPoint(i * 3, (i + 1) * 10, labels[i]); } const sX = parseFloat(document.querySelector(".js-scaleX").value); const sY = parseFloat(document.querySelector(".js-scaleY").value); const tX = parseFloat(document.querySelector(".js-transX").value); const tY = parseFloat(document.querySelector(".js-transY").value); fig.scale(sX, sY); fig.translate(tX, tY); fig.draw(); } Array .from(document.querySelectorAll("input")) .forEach(el => el.addEventListener("change", init)); init(); // Utilities for drawing function drawDot(ctx, d, x, y) { ctx.arc(x, y, d / 2, 0, 2 * Math.PI); } function drawLabel(ctx, label, x, y) { ctx.fillText(label, x, y); }
 canvas { background: #efefef; margin: 1rem; } input { width: 50px; }
 <div> <p> Scales first, translates second (hard coded, can be changed) </p> <label>Scale x <input type="number" class="js-scaleX" value="1"></label> <label>Scale y <input type="number" class="js-scaleY" value="1"></label> <br/> <label>Translate x <input type="number" class="js-transX" value="0"></label> <label>translate y <input type="number" class="js-transY" value="0"></label> </div> <canvas id="canvas" width="250" height="250"></canvas>

Note: use the inputs for an example of how this works. I've chosen to "commit" the changes from scale and translate immediately, so order matters! You might want to press full-screen to get both canvas and inputs in to view.

Alternative solution

  var x = 100;
  var y = 100;
  var pixelRatio = 2;
  var transform = {"x": 0, "y": 0, "k": 1}

  context.save();
  context.setTransform(pixelRatio, 0.0, 0.0, pixelRatio, 0.0, 0.0);
  context.translate(transform.x, 0);
  context.scale(transform.k, 1);

  context.save();
  // get Transformed Point
  var context_transform = context.getTransform();
  var pt = context_transform.transformPoint({
        x: x,
        y: y
      });

  // Reset previous transforms
  context.setTransform(pixelRatio, 0.0, 0.0, pixelRatio, -pt.x, -pt.y);
  

  // draw with the values as usual
  context.textAlign = "left";
  context.font = "14px Arial";
  context.fillText("Hello", pt.x, pt.y);
  context.restore();

  context.restore();

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM