tsayen / dom-to-image

Generates an image from a DOM node using HTML5 canvas

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Why is it blurry?

davidascher opened this issue · comments

Use case: description, code

Here's a test case:

https://ns-pnhsrgpbju.now.sh/

(click on imageify button)

Expected behavior

Ideally the image would be a pixel-perfect match for the original.

Actual behavior (stack traces, console logs etc)

The image is quite a bit blurrier, and I've even seen it render using serif fonts instead of the sans-serif font.

image

Library version

2.5.2

Browsers

  • Chrome 49+ (53.0.2785.116 to be exact)
  • Firefox 45+ (48.0 to be exact)

I've made sure that the code specifies quality=1, but that's not having any effect.

(really cool project by the way!)

I'm not a computer graphics expert, but it might be the way browser handles images. The HTML rendered by the browser is like vector graphics, and PNG image is a raster graphics. There rather will be some slight difference in how the browser renders them - so they might look differently. To check that, what if you try to make a print screen (the "native" one, which for sure should be a "pixel-perfect" copy), cut the relevant part and append it to your page - does it look different?

Also, try to open the generated image in an image viewer instead of browser (also make sure you've turned off any "smoothing" or "interpolation", or whatever it's called there) - is it still "blurry"?

PS just tried your app myself - frankly speaking I don't see that much of a difference (I'm using FF 49 on Ubuntu):

screenshot from 2016-09-29 10-25-14

Yes, there's obviously rasterization going on here with the text (and circle) rendering. I suppose what I'm asking for are some knobs on the resolution of the buffer used, esp. when it comes to text rendering.

I'll poke at the code and see if I can figure out where this is happening.

First, here is a sample from Chrome/Windows:
image

The DOM-drawn text is on top, dom-to-image output text on bottom. In this sample, the difference in sharpness can perhaps be chalked up to the top using sub-pixel rendering of the text and the bottom just standard anti-aliasing.

However, in your sample there is no sub-pixel rendering. I haven't looked at the code, but perhaps this is attributable to the text getting rasterized and then accidentally fitted to a half-pixel boundary that the canvas (and I think SVG?) uses? If you're not aware of this issue, have a look at this image:

image

The squares are screen pixels. Canvas coordinates actually refer to the boundaries between the pixels. So the line intended to be 1 pixel wide (dark blue) in the second panel, going from (3,1) to (3,5) doesn't actually get drawn that way. It gets anti-aliased to the light blue color and ends up two pixels wide. (The result isn't shown in that image, but picture that second column with the dark blue part actually colored light blue). The solution in this case is to use half pixel coordinates, as shown in the third panel. Doing that, the line stays sharp. A writeup is available on this page, which is where I snagged the image from.

Any chance a similar half-pixel issue is somehow related?

Hi. Thanks guys for your research. It really might be that the difference is due to the sub-pixel rendering being used by browser for HTML and not being used for rendering text on canvas. This lib renders HTML to image by throwing it onto canvas - so if you want to tweak the rendering itself, you should be looking for some Canvas options like "enable subpixel rendering". I would think of providing such options to the lib users, I just can't find anything about tweaking the Canvas that way at the moment.

Generally I wonder what should be done with this issue?

I've had to do quite a bit of canvas investigation lately. I've now got some insight. The two samples provided (mine and davidascher's) are actually blurry for two different reasons.

David's is blurry because he is using a high-dpi device where one CSS/HTML/SVG pixel is equal 4 (2x2) device pixels. Note how your "320 pixel" high SVG (according to the code) is actually being rendered at 640 pixels high. The browser uses this extra resolution to make the drawing sharper (the text and SVG drawing can use all of the device pixels--a non-shrunk bitmap couldn't). When dom-to-image renders this on canvas, it is rendered to a "correct" 320px high bitmap which in your screenshot is then blown up to 640 pixels by your OS/browser to match sizing for your high dpi display. dom-to-image can correct for this (I can provide a code snippet of the concept, tsayen, if interested), but that may not or may not be desirable because as mentioned above, your "320px" high DOM element would result in different size canvas render depending each user's dpi settings. On the plus side, for a given user, the rendered canvas would look the same as the rendered html document when shown on their computer.

My sample looked sharper because of subpixel rendering. You can trigger this to happen on the canvas too by making an opaque output canvas. The most standard way to do this is when getting the context, with something like: "canvas.getContext('2d', { alpha: false });." and then do your drawing on that. Gecko browsers can be set opaque with canvas.mozOpaque = true;

I think the current behavior is the best default, but both of the above things would be nice options to be able to be turned on.

commented

@xorlof @tsayen Thank you guys for having the discussion. Haven't dug through the code yet, but Is there a particular like place where it would be best to place the line "canvas.getContext('2d', { alpha: false });." like @xorlof mentioned ?

Not working in Android Version < 4.4. Working in Android Version 5. Anyone know why ?

@xorlof Would love a code sample here. The way I'm rendering is below and I'm wondering if there's a place where I can simply say "generate it twice as big" to account for retina screens:

  domtoimage.toPng(document.getElementById('app'))
    .then(dataUrl => {
      const link = document.createElement('a');
      link.download = 'image.png';
      link.href = dataUrl;
      link.click();
    })

I had the same issue and found this fork: retina-dom-to-image

It generates a 400px image out of a 200px node. So if you open this image on a retina device, it will still look blurry at 100% zoom. I tried using link.download = 'my-image-name-@2x.png'; to automatically download a file with the suffix @2x to see if my device would show it as a crisp "200px" image, but it doesn't.

newCanvas function
function newCanvas(domNode) {
let canvas = document.createElement('canvas');
const defaultW = util.width(domNode);
const defaultH = util.height(domNode);
// 控制放大倍数
canvas.width = Math.floor(options.width || defaultW * (options.scale || 1));
canvas.height = Math.floor(options.height || defaultH * (options.scale || 1));
canvas.setAttribute('style', width: ${canvas.width}px; height: ${canvas.height}px;);
let ctx = canvas.getContext('2d');
if (options.scale) {
ctx.scale(options.scale, options.scale);
}
if (options.bgcolor) {
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}

    return canvas;
}

try scale

commented

Here is a workaround to make it retina resolution:

const scale = 2;
const image = await toPng(elm, {
  height: elm.offsetHeight * scale,
  style: {
    transform: `scale(${scale}) translate(${elm.offsetWidth / 2 / scale}px, ${elm.offsetHeight / 2 / scale}px)`
  },
  width: elm.offsetWidth * scale
});
commented

@el

const scale = 2;
const image = await toPng(elm, {
  height: elm.offsetHeight * scale,
  style: {
    transform: `scale(${scale}) translate(${container.offsetWidth * (scale - 1) / 2 / scale}px, ${container.offsetHeight * (scale - 1) / 2 / scale}px)`
  },
  width: elm.offsetWidth * scale
});

The actual use of the above scheme may have a display bug.
This is what I use.
Generate a fixed-width image, such as 750px wide.
(translate.google.cn)

const node = this.$refs.screenshot;
const scale = 750 / node.offsetWidth;
this.shot_loading = true;

domtoimage
.toPng(node, {
    height: node.offsetHeight * scale,
    width: node.offsetWidth * scale,
    style: {
    transform: "scale(" + scale + ")",
    transformOrigin: "top left",
    width: node.offsetWidth + "px",
    height: node.offsetHeight + "px"
    }
})
.then(dataUrl => {
    this.baseData = dataUrl;
    this.shot_loading = false;
})
.catch(error => {
    this.shot_loading = false;
    console.error("oops, something went wrong!", error);
});

thanks @prettyboyweiwei that solved my problem

Thanks a lot @prettyboyweiwei

const scale = 2;
var node = document.getElementById('area');
let obj = {
  height: window.innerHeight * scale,
  style: {
	transform: `scale(${scale}) translate(${window.innerWidth / 2 / scale}px, ${window.innerHeight / 2 / scale}px)`
  },
  width: window.innerWidth * scale
}
domtoimage.toPng(node, obj)
  .then((dataUrl) => {
	console.log(dataUrl);
	this.imag = this.sanitizer.bypassSecurityTrustUrl(dataUrl);
  })
  .catch((error) => {
	console.error('oops, something went wrong!', error);
  });

Tuning code for angular credit @el Thanks

The actual use of the above scheme may have a display bug.
This is what I use.
Generate a fixed-width image, such as 750px wide.
(translate.google.cn)

const node = this.$refs.screenshot;
const scale = 750 / node.offsetWidth;
this.shot_loading = true;

domtoimage
.toPng(node, {
    height: node.offsetHeight * scale,
    width: node.offsetWidth * scale,
    style: {
    transform: "scale(" + scale + ")",
    transformOrigin: "top left",
    width: node.offsetWidth + "px",
    height: node.offsetHeight + "px"
    }
})
.then(dataUrl => {
    this.baseData = dataUrl;
    this.shot_loading = false;
})
.catch(error => {
    this.shot_loading = false;
    console.error("oops, something went wrong!", error);
});

Thanks !!! @prettyboyweiwei

Many Thanks !!! @prettyboyweiwei

@prettyboyweiwei works, thanks a lot!!

@prettyboyweiwei works thanks :)

My code with jspdf

const scale = 2;
                const dataUrl = await domtoimage.toPng(elm, {
                    height: 2130,
                    width: 1100,
                    style: {
                        transform: "scale(" + 2 + ")",
                        transformOrigin: "top left",
                        width: elm.offsetWidth + "px",
                        height: elm.offsetHeight + "px"
                    }
                });
                doc.addImage(dataUrl, 'JPEG', 0, 0, 414, 800);
                if (i < (idForPrintPage)) {
                    doc.addPage({
                        orientation: 'p',
                        unit: 'px',
                        format: [432, 1056]
                    });
                }

If anyone is still having this issue, I have finally found a solution that handles the DPI of the image as well. My main issue I've been struggling with is that I'd like the output image to appear the same size when the file is opened (in my case on MacOS) and to do that you'd also need to change the DPI of the image from 72 to 144. To do this I used the changeDPI package from shutterstock. Here is my modified solution to implement this:

npm install --save changedpi
import { toPng } from 'dom-to-image';
import { changeDpiDataUrl } from 'changedpi';

const BASE_DPI = 72;
const scale = 2;

let dataUrl = await toPng(el, {
  height: el.offsetHeight * scale,
  width: el.offsetWidth * scale,
  style: {
    transform: `scale(${scale})`,
    transformOrigin: 'top left',
    width: `${el.offsetWidth}px`,
    height: `${el.offsetHeight}px`
  }
});

dataUrl = changeDpiDataUrl(dataUrl, BASE_DPI * scale);

// handle output here

if you want the output image to match the DPI of the monitor it is being displayed on, you could use the following:

import { toPng } from 'dom-to-image';
import { changeDpiDataUrl } from 'changedpi';

const BASE_DPI = 72;
const scale = window.devicePixelRatio;

let dataUrl = await toPng(el, {
  height: el.offsetHeight * scale,
  width: el.offsetWidth * scale,
  style: {
    transform: `scale(${scale})`,
    transformOrigin: 'top left',
    width: `${el.offsetWidth}px`,
    height: `${el.offsetHeight}px`
  }
});

dataUrl = changeDpiDataUrl(dataUrl, BASE_DPI * scale);

// handle output here

The basis for this solution is from @prettyboyweiwei, thank you!

@csandman Do you know anything about the window.devicePixelRatio.

It seems it is affecting the output size. Please check my issue.

@csandman Do you know anything about the window.devicePixelRatio.

It seems it is affecting the output size. Please check my issue.

@DemChing I have never run into the issue you're describing, changing my zoom level to make my window.devicePixelRatio to 1.25 isn't affecting the output. Do you have this set in your html <head>?

<meta name="viewport" content="width=device-width, initial-scale=1" />

I have no idea if that is the issue but if not, please post more information and code in your issue.

Just use this fork instead: https://github.com/bubkoo/html-to-image

It makes it more usable, with built-in functionality such as pixelRatio, canvasHeight, etc.

Thanks a lot @prettyboyweiwei I've been trying to do it the way you wrote and it didn't work until I copied your exact code. hahaha, Thanks, man.

The actual use of the above scheme may have a display bug. This is what I use. Generate a fixed-width image, such as 750px wide. (translate.google.cn)

const node = this.$refs.screenshot;
const scale = 750 / node.offsetWidth;
this.shot_loading = true;

domtoimage
.toPng(node, {
    height: node.offsetHeight * scale,
    width: node.offsetWidth * scale,
    style: {
    transform: "scale(" + scale + ")",
    transformOrigin: "top left",
    width: node.offsetWidth + "px",
    height: node.offsetHeight + "px"
    }
})
.then(dataUrl => {
    this.baseData = dataUrl;
    this.shot_loading = false;
})
.catch(error => {
    this.shot_loading = false;
    console.error("oops, something went wrong!", error);
});

const node = this.$refs.screenshot;

so, I'm just using vanilla js and I know very little. What is "const node = this.$refs.screenshot;"?

I've been using document.getElementById(id);

@Kylar182 This is the syntax of vuejs, Can be used to get an element or component instance. https://vuejs.org/api/built-in-special-attributes.html#ref

const node = this.$refs.screenshot;
or
const node = document.getElementById(id);
or
whatever

Anything works as long as node is a dom element

@Kylar182 This is the syntax of vuejs, Can be used to get an element or component instance. https://vuejs.org/api/built-in-special-attributes.html#ref

const node = this.$refs.screenshot;
or
const node = document.getElementById(id);
or
whatever

Anything works as long as node is a dom element

K, I ended up doing this

async function OnBCExport(id) {
  const node = document.getElementById(id);
  const scale = 1200 / node.offsetWidth;

  try {
    return domtoimage.toPng(node, {
      height: node.offsetHeight * scale,
      width: node.offsetWidth * scale,
      style: {
        transform: "scale(" + scale + ")",
        transformOrigin: "top left",
        width: node.offsetWidth + "px",
        height: node.offsetHeight + "px",
      },
    });
  } catch (error) {
    console.error("ONBCExport Error!" + id, error);
    return String(error);
  }
}

I return it as a string and save the image locally as this is a hybrid app. It looks amazing and works very well, thanks again!