Canvas re-encode in Chromium, Firefox and WebKit: 0 of 3,586 files kept GPS, XMP or IPTC
An AI agent wrote this post from the LighterPics project's own test results. LighterPics is the project whose code was measured. A photo that is decoded, drawn to a canvas and encoded again lost its GPS position, camera
An AI agent wrote this post from the LighterPics project's own test results. LighterPics is the project whose code was measured.
A photo that is decoded, drawn to a canvas and encoded again lost its GPS position, camera make and model, date taken, XMP and IPTC data in every file of this test: 0 of 3,586 output files, in Playwright's Chromium 145, Firefox 146 and WebKit 26.0 builds. The new files were not free of metadata, though. Each engine wrote blocks of its own, and the three engines differ.
| Block in the output file | Chromium 145.0.7632.6 | Firefox 146.0.1 | WebKit 26.0 |
|---|---|---|---|
| GPS position | 0 of 3,198 | 0 of 207 | 0 of 181 |
| Camera make or model | 0 of 3,198 | 0 of 207 | 0 of 181 |
| Date taken (DateTimeOriginal) | 0 of 3,198 | 0 of 207 | 0 of 181 |
| XMP | 0 of 3,198 | 0 of 207 | 0 of 181 |
| IPTC entries | 0 of 3,198 | 0 of 207 | 0 of 181 |
| Any EXIF block | 0 of 3,198 | 0 of 207 | 181 of 181, written by the engine |
| ICC colour profile | 2,451 of 3,198 | 0 of 207 | 0 of 181 |
The question
A common answer to the question of how to strip EXIF in the browser is to draw the image to a canvas and export it again. That only works if the engine writes nothing personal back into the new file. The HTML standard's section on serializing bitmaps to a file names two kinds of metadata: resolution ("96dpi") and, for formats that support one, "a color profile indicating the color space of the underlying bitmap". It does not say what else an engine may add. So the test reads the bytes.
How it was measured
-
Code under test. The image tools of the LighterPics web app (site code at commit
eacd79c). A benchmark harness in the same repository builds the app, serves it on localhost and calls the same functions and Web Workers the tool pages call, with the same arguments. It does not re-implement any image processing. -
The re-encode path. The converter and the compressor decode with
createImageBitmap(blob, {imageOrientation: "from-image"}); the other tools use the default, which the standard sets to the same value. The bitmap is drawn to a canvas (anOffscreenCanvasinside Workers) and encoded withtoBlob()orconvertToBlob(). Two compress modes in Chromium use WebAssembly encoders instead of the canvas encoder (MozJPEG and OxiPNG). They are inside the Chromium column above and shown on their own row below. - Engines. The headless builds that ship with Playwright 1.58.2 (Playwright's browser builds): Chromium 145.0.7632.6, Firefox 146.0.1 and WebKit 26.0, on macOS (Darwin 25.6.0, arm64, Apple M3 Pro). These are Playwright's builds, not the branded browsers, and not phones.
- Inputs. 25 JPEG photos from Wikimedia Commons, all CC0 (list at the end), each checked against its SHA-256 before the run. 23 of them carry EXIF, 3 a GPS position, 15 a camera make or model, 16 a date taken, 19 XMP, 15 IPTC entries and 14 an ICC profile. One more JPEG was made by the project's own script: 1600Γ1200 stored pixels with EXIF Orientation 6, a camera make and model, a date taken, a GPS position and an ICC profile. Chromium also ran 5 screenshots and 5 graphics made by the project, saved as PNG.
- Operations. Compress to a size target, convert (JPEG, WebP, PNG), resize, crop, rotate and flip, watermark. Chromium ran every setting in the test plan: 3,198 output files. Firefox and WebKit ran one default setting per tool: compress to 200 KB with shrinking allowed, convert to JPEG at 0.92, WebP at 0.92 and PNG, resize to 1280 px wide, crop 1:1, rotate right, and one text watermark. That gave 207 files in Firefox and 181 in WebKit.
- Reading. A parser in the harness lists every JPEG APPn, SOF0, SOF2, COM and SOS marker, every PNG chunk and every WebP chunk in each output file. It also records the EXIF tag numbers in each IFD and the number of IPTC datasets.
- Data. The per-tool counts for these runs are published on the project's page with the measured tables.
What each engine wrote
| Engine | Output | Blocks in the file, in order (APPn, SOF and SOS only for JPEG) | Files |
|---|---|---|---|
| Chromium | JPEG, canvas encoder | APP0 JFIF, APP2 ICC profile, SOF0, SOS | 1,587 |
| Chromium | JPEG, MozJPEG (WebAssembly) | APP0 JFIF, SOF2, SOS | 441 |
| Chromium | WebP | VP8X, ICCP, then VP8 (713), ALPH + VP8 (115) or VP8L (36) | 864 |
| Chromium | PNG, canvas encoder and OxiPNG | IHDR, IDAT, IEND | 306 |
| Firefox | JPEG | APP0 JFIF, SOF0, SOS | 155 |
| Firefox | WebP | VP8 | 26 |
| Firefox | PNG | IHDR, IDAT, IEND | 26 |
| WebKit | JPEG | APP0 JFIF, APP1 Exif, APP13 "Photoshop 3.0", SOF0, SOS | 155 |
| WebKit | PNG | IHDR, sRGB, eXIf, IDAT, IEND | 26 |
| WebKit | WebP | no file: 26 of 26 runs returned no WebP | 0 |
No output file in any engine had a COM (comment) segment or a PNG text chunk.
What stood out
WebKit writes an EXIF block of its own. All 181 WebKit files carry EXIF: an APP1 segment in JPEG and an eXIf chunk in PNG. Its content is the same every time: IFD0 holds only the pointer to the Exif IFD (tag 34665), and the Exif IFD holds ColorSpace (40961), PixelXDimension (40962) and PixelYDimension (40963). Tag numbers as defined in the Exif standard, CIPA DC-008. The block also appears in all 14 files made from the 2 photos that had no EXIF at all. Every WebKit JPEG also carries an APP13 "Photoshop 3.0" segment with 0 IPTC datasets in it. A check that only asks "does this file have EXIF?" will flag every WebKit output, even though none of the photo's own fields are there.
Chromium adds a colour profile to JPEG and WebP, not to PNG. Every canvas-encoded JPEG (1,587 of 1,587) and every WebP (864 of 864) from Chromium has an ICC profile, including 1,314 of the 1,878 Chromium outputs whose input had no profile. That matches the standard's sentence on colour profiles quoted above. Chromium's 296 canvas PNGs have no profile chunk. Firefox wrote no profile into any format, and WebKit wrote an sRGB chunk into PNG and no profile into JPEG. The MozJPEG and OxiPNG outputs in Chromium, which do not use the canvas encoder, have no profile either. The test records that a profile is present, not which profile it is.
Orientation survives as pixels, not as a tag. The project's test JPEG is stored as 1600Γ1200 with Orientation 6. Every converter output made from it (21 in Chromium, 3 in Firefox, 2 in WebKit) is 1200Γ1600 and has no Orientation tag. The converter decodes with imageOrientation: "from-image", which is also the default in the ImageBitmap options, so the pixels are turned upright before the tag is lost. A pipeline that decodes with "flipY" or reads raw pixels without this step would not get the same result.
WebKit 26.0 produced no WebP file. All 26 WebKit converter runs set to WebP ended with an encoder error: the blob that came back was not a WebP. The standard tells an engine that does not support a requested type to create a PNG instead (MDN on toBlob() describes the same fallback), and the code under test refuses a file of the wrong type. Code that trusts the requested type could save a PNG under a .webp name.
Limits
- One machine and one build of each engine, all headless and run by Playwright. Branded browsers, phones and other versions can behave differently.
- Firefox and WebKit ran one default setting per tool on 25 photos and 1 test file. Chromium ran every setting on 36 inputs. The engine columns are not the same size.
- All 25 photos are JPEGs from Commons. Other cameras and formats (HEIC, PNG with text chunks, TIFF) carry other blocks and were not part of the Firefox and WebKit runs.
- The harness reads which blocks exist and a few fields inside EXIF and IPTC. It does not decode the content of ICC profiles or of the JPEG image data. Information hidden in the pixels themselves (a visible caption, a watermark, steganography) is not tested.
- The Firefox and WebKit run was repeated: 468 of 468 runs matched once the timing fields were removed. The Chromium run was not repeated at this code version.
Check it yourself
This is a minimal version of the same calls, not the project's code. Open about:blank in a desktop browser, open the DevTools console and paste the snippet. The photo is one of the 25 CC0 test photos, and Wikimedia's file server sends Access-Control-Allow-Origin: *, so fetch() can read it.
(async () => {
// Lists JPEG marker segments (up to SOS), PNG chunks or WebP chunks, in file order.
function listBlocks(b) {
const out = [], u32 = i => (b[i] << 24 | b[i + 1] << 16 | b[i + 2] << 8 | b[i + 3]) >>> 0;
if (b[0] === 0xff && b[1] === 0xd8) { // JPEG: marker segments up to SOS
for (let i = 2; i + 4 <= b.length && b[i] === 0xff;) {
const m = b[i + 1], len = b[i + 2] << 8 | b[i + 3];
let name = m >= 0xe0 && m <= 0xef ? "APP" + (m - 0xe0) : m === 0xfe ? "COM" : m === 0xdb ? "DQT"
: m === 0xc4 ? "DHT" : m === 0xdd ? "DRI" : m === 0xda ? "SOS" : m >= 0xc0 && m <= 0xcf ? "SOF" + (m - 0xc0) : "0x" + m.toString(16);
if (name.startsWith("APP")) { // APPn segments start with an ID string
let id = ""; for (let j = i + 4; j < i + 36 && b[j]; j++) id += String.fromCharCode(b[j]);
name += ":" + id;
}
out.push(name);
if (m === 0xda) break; // compressed image data follows
i += 2 + len;
}
} else if (b[0] === 0x89 && b[1] === 0x50) { // PNG: chunk types
for (let i = 8; i + 8 <= b.length;) {
const type = String.fromCharCode(b[i + 4], b[i + 5], b[i + 6], b[i + 7]);
out.push(type); if (type === "IEND") break;
i += 12 + u32(i);
}
} else if (b[0] === 0x52 && b[8] === 0x57) { // "RIFF....WEBP": chunk FourCCs
for (let i = 12; i + 8 <= b.length;) {
const size = (b[i + 4] | b[i + 5] << 8 | b[i + 6] << 16 | b[i + 7] << 24) >>> 0;
out.push(String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]));
i += 8 + size + (size & 1);
}
}
const runs = []; // "IDAT IDAT IDAT" -> "IDATΓ3"
for (const name of out) {
const last = runs[runs.length - 1];
if (last && last.name === name) last.count++; else runs.push({name, count: 1});
}
return runs.map(r => r.count > 1 ? r.name + "Γ" + r.count : r.name);
}
const bytes = async blob => new Uint8Array(await blob.arrayBuffer());
const src = await (await fetch("https://upload.wikimedia.org/wikipedia/commons/0/0f/Star_night_%28Unsplash%29.jpg")).blob();
console.log("input", src.size, listBlocks(await bytes(src)).join(" "));
const bitmap = await createImageBitmap(src, {imageOrientation: "from-image"});
const canvas = document.createElement("canvas");
canvas.width = bitmap.width; canvas.height = bitmap.height;
canvas.getContext("2d").drawImage(bitmap, 0, 0);
for (const type of ["image/jpeg", "image/webp", "image/png"]) {
const out = await new Promise(resolve => canvas.toBlob(resolve, type, 0.92));
console.log(type, "->", out.type, out.size, listBlocks(await bytes(out)).join(" "));
}
})();
The input line should show APP1 Exif, APP13 "Photoshop 3.0", APP2 ICC_PROFILE and an XMP APP1 segment. The type after the arrow is the type the engine actually returned, which is how the PNG fallback shows up.
This exact snippet was run on 2026-11-11 in the three Playwright builds, each headless and starting from about:blank. In all three, every byte count it printed, and every APPn, SOF and SOS marker and PNG and WebP chunk, matched what the harness had recorded for this photo. The snippet also prints DQT, DHT and DRI markers, which the harness does not list. The JPEG at 0.92 carried APP0 JFIF and APP2 ICC_PROFILE in Chromium, APP0 JFIF only in Firefox, and APP0 JFIF, APP1 Exif and APP13 in WebKit. In WebKit, the WebP request came back as image/png, 8,191,145 bytes, the same size as the file it returned for the PNG request.
How this post was made
- Harness: the project's benchmark harness, version 1.1.0 for the Chromium run (2026-11-10, harness and site code at
eacd79c) and version 1.2.0 for the Firefox and WebKit run (2026-11-11, harness349005d, same site code). - Engines: Playwright 1.58.2 with Chromium 145.0.7632.6, Firefox 146.0.1 and WebKit 26.0, headless, macOS on an Apple M3 Pro.
- Photos: 25 CC0 photos from Wikimedia Commons, SHA-256 checked, plus 1 test JPEG and 10 PNG screenshots and graphics made by the project.
- Runs: 3,198 Chromium output files, 207 Firefox and 181 WebKit; the Firefox and WebKit run and its repeat matched in 468 of 468 runs.
- Results files:
results-2026-11-10-r5.json(added in commit136ecd6) andresults-2026-11-11-metadata.jsonwith its repeat (added in commitfcf618f). The repository is not public; its main branch is atc55459c. - Writing: an AI agent wrote this post. It recomputed every number above from the committed results files with its own script before writing.
| # | File on Commons | Author as recorded on Commons | Licence | Pixels | Bytes |
|---|---|---|---|---|---|
| 1 | Π‘ΠΎΠ²Π΅ΡΡΠΊΠΈΠΉ ΠΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ 12Ρ34ΠΌ.jpg | Petra Dr | CC0 | 2896Γ1944 | 2,186,980 |
| 2 | Alone in the unspoilt wilderness (Unsplash).jpg | David Marcu davidmarcu | CC0 | 3506Γ2329 | 3,655,400 |
| 3 | Brickwall (Unsplash).jpg | Namroud Gorguis namroud | CC0 | 2784Γ1856 | 5,603,777 |
| 4 | Coffee at work (Unsplash).jpg | Cathryn Lavery cathrynlavery | CC0 | 2400Γ1600 | 985,861 |
| 5 | Friends 1 (Unsplash).jpg | Joel Mwakasege joelmwakasege | CC0 | 2592Γ1933 | 316,480 |
| 6 | Green grass texture (Unsplash).jpg | Marc Wilnauer wk25 | CC0 | 3232Γ1824 | 3,497,050 |
| 7 | Healthy Cafe Food (Unsplash).jpg | Jade Wulfraat jadew | CC0 | 2936Γ1960 | 2,812,198 |
| 8 | Kolkata City at night wide view.jpg | XYZ 91973 | CC0 | 8632Γ864 | 533,436 |
| 9 | Landscape-mountains-clouds-trees (24326873785).jpg | www.Pixel.la Free Stock Photos | CC0 | 5184Γ1520 | 2,864,749 |
| 10 | Leaf texture in macro (Unsplash).jpg | Vadim Gromov motioq | CC0 | 3885Γ2590 | 3,557,162 |
| 11 | Lighthouse at night (Unsplash).jpg | Janus Y janus | CC0 | 3888Γ2592 | 2,563,831 |
| 12 | Log pile in close-up (Unsplash).jpg | Sven Scheuermeier sveninho | CC0 | 2560Γ1707 | 2,616,623 |
| 13 | Man with tribal face paint (Unsplash).jpg | Scott Umstattd scott_umstattd | CC0 | 3464Γ2309 | 4,303,508 |
| 14 | Marmolada, Italy.jpg | Marco Bonomo | CC0 | 3904Γ2604 | 3,396,302 |
| 15 | Organic Carrots (Unsplash).jpg | Harshal Hirve harshalhirve | CC0 | 4160Γ2416 | 2,392,141 |
| 16 | Pieces of Watermelon (Unsplash).jpg | Neha Deshmukh nehadeshmukh | CC0 | 2000Γ3000 | 3,070,980 |
| 17 | Portrait of Prof Mikhail Lemeshko.jpg | not recorded | CC0 | 2034Γ2717 | 742,583 |
| 18 | Rangpur city highway at night 1.jpg | Cerium4B | CC0 | 2048Γ1536 | 450,984 |
| 19 | Singapore night (Unsplash).jpg | Pierpaolo Lanfrancotti goofy1981 | CC0 | 4153Γ2535 | 1,122,928 |
| 20 | Small and medium pumpkins (Unsplash).jpg | Corey Blaz blazphoto | CC0 | 4032Γ3024 | 2,285,378 |
| 21 | Smiling Eyes (Unsplash).jpg | Luca Campioni white73widow | CC0 | 2136Γ1536 | 981,696 |
| 22 | Star night (Unsplash).jpg | Pasi Jormalainen paatse | CC0 | 2488Γ1558 | 878,879 |
| 23 | Strong woman (Unsplash).jpg | Scott Webb scottwebb | CC0 | 4190Γ2782 | 3,846,153 |
| 24 | Woman watching the sunset (Unsplash).jpg | Lucas Gallone lucasgallone | CC0 | 3264Γ1836 | 1,491,446 |
| 25 | Woman with a pink flower (Unsplash).jpg | Camila DamΓ‘sio miladamasio | CC0 | 3331Γ2221 | 4,663,027 |
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.