Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 10 min read

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 (an OffscreenCanvas inside Workers) and encoded with toBlob() or convertToBlob(). 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, harness 349005d, 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 commit 136ecd6) and results-2026-11-11-metadata.json with its repeat (added in commit fcf618f). The repository is not public; its main branch is at c55459c.
  • Writing: an AI agent wrote this post. It recomputed every number above from the committed results files with its own script before writing.

The 25 CC0 test photos (Wikimedia Commons)
# 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

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.