Why are thin SVG lines blurry after converting to PNG?
The half-pixel shift sharpened rather than blurred the 1-unit line at 1×. This finding applies only to these samples and this browser rendering run.
These measurements were taken on October 26, 2026.
The decoded results
Each SVG contains one horizontal black line from x=4 to x=28 on a transparent 32×32 canvas, with butt caps. The two stroke widths are 1 and 0.5 SVG units; the two center coordinates are y=16 and y=16.5. Every file was converted on the public SVGtopix converter, not recreated in a separate renderer.
We decoded each downloaded PNG with Pillow. Sampling follows a vertical center-column profile across every row: x=16 for 32×32 outputs and x=32 for 64×64 outputs. This crosses the horizontal line. A transition pixel means 0<alpha<255 in this profile. Occupied image rows were found by scanning all pixels for alpha>0. Coordinates are zero-based.
| Sample / stroke width / source y | Sampling coordinates | Output size | Export scale | Render mode | Transition pixels | Occupied image rows | Observed result: row → alpha / gray |
|---|---|---|---|---|---|---|---|
| w1-y16 / 1 / 16 | x=16; y=0–31 | 32×32 | 1× | Standard | 2 | 15, 16 | 15 → 127 / 0; 16 → 128 / 0 |
| w1-y16 / 1 / 16 | x=16; y=0–31 | 32×32 | 1× | High | 2 | 15, 16 | 15 → 127 / 0; 16 → 127 / 0 |
| w1-y16 / 1 / 16 | x=32; y=0–63 | 64×64 | 2× | Standard | 0 | 31, 32 | 31 → 255 / 0; 32 → 255 / 0 |
| w1-y16 / 1 / 16 | x=32; y=0–63 | 64×64 | 2× | High | 0 | 31, 32 | 31 → 255 / 0; 32 → 255 / 0 |
| w1-y16p5 / 1 / 16.5 | x=16; y=0–31 | 32×32 | 1× | Standard | 0 | 16 | 16 → 255 / 0 |
| w1-y16p5 / 1 / 16.5 | x=16; y=0–31 | 32×32 | 1× | High | 0 | 16 | 16 → 255 / 0 |
| w1-y16p5 / 1 / 16.5 | x=32; y=0–63 | 64×64 | 2× | Standard | 0 | 32, 33 | 32 → 255 / 0; 33 → 255 / 0 |
| w1-y16p5 / 1 / 16.5 | x=32; y=0–63 | 64×64 | 2× | High | 0 | 32, 33 | 32 → 255 / 0; 33 → 255 / 0 |
| w0p5-y16 / 0.5 / 16 | x=16; y=0–31 | 32×32 | 1× | Standard | 2 | 15, 16 | 15 → 63 / 0; 16 → 63 / 0 |
| w0p5-y16 / 0.5 / 16 | x=16; y=0–31 | 32×32 | 1× | High | 2 | 15, 16 | 15 → 63 / 0; 16 → 64 / 0 |
| w0p5-y16 / 0.5 / 16 | x=32; y=0–63 | 64×64 | 2× | Standard | 2 | 31, 32 | 31 → 127 / 0; 32 → 128 / 0 |
| w0p5-y16 / 0.5 / 16 | x=32; y=0–63 | 64×64 | 2× | High | 2 | 31, 32 | 31 → 127 / 0; 32 → 127 / 0 |
| w0p5-y16p5 / 0.5 / 16.5 | x=16; y=0–31 | 32×32 | 1× | Standard | 1 | 16 | 16 → 127 / 0 |
| w0p5-y16p5 / 0.5 / 16.5 | x=16; y=0–31 | 32×32 | 1× | High | 1 | 16 | 16 → 127 / 0 |
| w0p5-y16p5 / 0.5 / 16.5 | x=32; y=0–63 | 64×64 | 2× | Standard | 2 | 32, 33 | 32 → 127 / 0; 33 → 128 / 0 |
| w0p5-y16p5 / 0.5 / 16.5 | x=32; y=0–63 | 64×64 | 2× | High | 2 | 32, 33 | 32 → 127 / 0; 33 → 127 / 0 |
All nontransparent profile pixels had stored R=G=B=0, so their decoded grayscale is 0. Partial transparency is recorded in alpha, not a lighter stored gray. We did not composite the samples onto white to manufacture a gray reading. Unlisted profile rows have alpha 0. The complete profiles, file sizes and SHA-256 values are in measurements.json.
Download every source and actual export
- w1-y16.svg · w1-y16-1x-standard.png · w1-y16-1x-high.png · w1-y16-2x-standard.png · w1-y16-2x-high.png
- w1-y16p5.svg · w1-y16p5-1x-standard.png · w1-y16p5-1x-high.png · w1-y16p5-2x-standard.png · w1-y16p5-2x-high.png
- w0p5-y16.svg · w0p5-y16-1x-standard.png · w0p5-y16-1x-high.png · w0p5-y16-2x-standard.png · w0p5-y16-2x-high.png
- w0p5-y16p5.svg · w0p5-y16p5-1x-standard.png · w0p5-y16p5-1x-high.png · w0p5-y16p5-2x-standard.png · w0p5-y16p5-2x-high.png
Names identify width and source y; for example, w0p5-y16p5 means width 0.5 at y=16.5. PNG names also identify final output scale and render mode. Source files are shared across the four settings, each still containing exactly one line.
What changed in this run?
For width 1 at 1× Standard, y=16 occupied rows 15 and 16 with alpha 127 and 128. Moving only the center to y=16.5 produced row 16 with alpha 255. At 2×, both width-1 positions had two fully opaque rows. In this pair, the half-pixel shift sharpened rather than blurred the center profile.
Width 0.5 retained partial alpha in every setting. At 1×, changing placement reduced the occupied profile from two rows to one without creating an opaque row. At 2× it still occupied two partially transparent rows. High sometimes changed alpha by one unit; it did not turn those profiles into opaque lines. These observations do not establish that one render mode is always sharper.
In a www-svg mailing-list post dated April 29, 2003, Dean Jackson, an SVG 1.1 specification editor, wrote: a one pixel stroke covers two halves of two pixels unless you have put your end point in the middle of a pixel.
This is a technical explanation in a historical email, not a requirement quoted from the specification. Our width-1 horizontal line at 1× is consistent with that explanation: the integer-coordinate sample spread across two partially transparent rows, while the half-pixel-shifted sample occupied one opaque row. This agreement is limited to that sample comparison and does not establish the same outcome for other stroke widths or rendering conditions.
Repeat or avoid this specific result
Download a source SVG and open SVG to PNG. Choose that file, leave Background set to Transparent, select 1× or 2×, then Standard or High. Convert and download. High uses a larger working canvas and downsamples; its name does not mean the downloaded image is twice the selected dimensions.
For this width-1, 1× fixture, y=16.5 is the measured opaque choice. Do not blindly move every SVG by half a pixel: the width-0.5 cases did not become opaque. Test the actual target output size before editing your artwork.
To decode again, place the downloaded PNGs and read-pixels.py together, install Pillow if needed, and run python read-pixels.py. The script prints dimensions, every center-column pixel, transition count and occupied rows. Read alpha separately from gray.
Limits
These results apply only to these source files and this browser rendering run. The browser engine version was not exposed by the available inspection interface. This was not a cross-browser, diagonal-line, font or visual-perception test. Transition count is a defined pixel measurement, not a universal blur score. No causal explanation beyond the controlled comparisons is claimed.