Usage • Installation • Quick start • Demos • API • Perf • Changelog • FAQ • Contributing
📷 The fastest and most versatile JavaScript EXIF reading library.
Try it yourself - demo page & playground.
Works everywhere, parses anything you throw at it.
GPSLatitude
, GPSLatitudeRef
tags ([50, 17, 58.57]
& "N"
) to single latitude
value (50.29960
).You don't need to read the whole file to tell if there's EXIF in it. And you don't need to extract all the data when you're looking for just a few tags. Exifr just jumps through the file structure, from pointer to pointer. Instead of reading it byte by byte, from beginning to end.
Exifr does what no other JS lib does. It's efficient and blazing fast!
Segments | JPEG | TIFF / IIQ | HEIF (HEIC, AVIF) | PNG |
---|---|---|---|---|
EXIF/TIFF, GPS | ✔ | ✔ | ✔ | ✔ |
XMP | ✔ | ✔ | ❌ | ✔ |
IPTC | ✔ | ✔ | ❌ | 🟡 (If it's a part of IHDR) |
ICC | ✔ | ✔ | ✔ | ✔ (Node.js only, requires zlib) |
Thumbnail | ✔ | ❌ | ❌ | ❌ |
JFIF (JPEG header) | ✔ | ⚫ | ⚫ | ⚫ |
IHDR (PNG header) | ⚫ | ⚫ | ⚫ | ✔ |
file
can be any binary format (Buffer
, Uint8Array
, Blob
and more), <img>
element, string path or url.
options
specify what segments and blocks to parse, filters what tags to pick or skip.
API | Returns | Description |
---|---|---|
exifr.parse(file) |
object |
Parses IFD0, EXIF, GPS blocks |
exifr.parse(file, true) |
object |
Parses everything |
exifr.parse(file, ['Model', 'FNumber', ...]) |
object |
Parses only specified tags |
exifr.parse(file, {options}) |
object |
Custom settings |
exifr.gps(file) |
{latitude, longitude} |
Parses only GPS coords |
exifr.orientation(file) |
number |
Parses only orientation |
exifr.rotation(file) |
object |
Info how to rotate the photo |
exifr.thumbnail(file) |
Buffer\|Uint8Array binary |
Extracts embedded thumbnail |
exifr.thumbnailUrl(file) |
string Object URL |
Browser only |
exifr.sidecar(file) |
object |
Parses sidecar file |
npm install exifr
Exifr comes in three prebuilt bundles. It's a good idea to start development with full
and then scale down to lite
, mini
, or better yet, build your own around modular core.
// Modern Node.js can import CommonJS
import exifr from 'exifr' // => exifr/dist/full.umd.cjs
// Explicily import ES Module
import exifr from 'exifr/dist/full.esm.mjs' // to use ES Modules
// CommonJS, old Node.js
var exifr = require('exifr') // => exifr/dist/full.umd.cjs
<!-- ES Module in modern browsers -->
<script type="module">import exifr from 'node_modules/exifr/dist/lite.esm.js';</script>
<!-- classic UMD script -->
<script src="https://cdn.jsdelivr.net/npm/exifr/dist/lite.umd.js"></script>
<!-- IE10 & old browsers. You also need Promise polyfill -->
<script src="https://cdn.jsdelivr.net/npm/exifr/dist/lite.legacy.umd.js"></script>
Browsers: lite
and mini
are recommended because of balance between features and file size. UMD format attaches the library to global window.exifr
object.
IE & old browsers: legacy
builds come bundled with polyfills. Learn more.
Of course, you can use the full
version in browser, or use any other build in Node.js.
import
syntax.require('exifr')
, AMD/RequireJS and global window.exifr
.Promise
polyfill. Learn more here.TL;DR: All bundles are available in two identical copies. .mjs
and .js
for ESM. .cjs
and .js
for UMD. Pick one that works with your tooling or webserver.
Exifr exports both named exports and a default export - object containing all the named exports.
You can use import * as exifr from 'exifr'
as well as import exifr from 'exifr'
(recommended).
// exifr reads the file from disk, only a few hundred bytes.
exifr.parse('./myimage.jpg')
.then(output => console.log('Camera:', output.Make, output.Model))
// Or read the file on your own and feed the buffer into exifr.
fs.readFile('./myimage.jpg')
.then(exifr.parse)
.then(output => console.log('Camera:', output.Make, output.Model))
Extract only certain tags
// only GPS
let {latitude, longitude} = await exifr.gps('./myimage.jpg')
// only orientation
let num = await exifr.orientation(blob)
// only three tags
let output = await exifr.parse(file, ['ISO', 'Orientation', 'LensModel'])
// only XMP segment (and disabled TIFF which is enabled by default)
let output = await exifr.parse(file, {tiff: false, xmp: true})
Extracting thumbnail
let thumbBuffer = await exifr.thumbnail(file)
// or get object URL (browser only)
img.src = await exifr.thumbnailUrl(file)
Web Worker
let worker = new Worker('./worker.js')
worker.postMessage('../test/IMG_20180725_163423.jpg')
worker.onmessage = e => console.log(e.data)
// tip: try Transferable Objects with ArrayBuffer
worker.postMessage(arrayBuffer, [arrayBuffer])
// worker.js
importScripts('./node_modules/exifr/dist/lite.umd.js')
self.onmessage = async e => postMessage(await exifr.parse(e.data))
UMD in Browser
<img src="https://github.com/MikeKovarik/exifr/raw/master/myimage.jpg">
<script src="https://github.com/MikeKovarik/exifr/raw/master/node_modules/exifr/dist/lite.umd.js"></script>
<script>
let img = document.querySelector('img')
window.exifr.parse(img).then(exif => console.log('Exposure:', exif.ExposureTime))
</script>
ESM in Browser
<input id="filepicker" type="file" multiple>
<script type="module">
import exifr from './node_modules/exifr/dist/lite.esm.js'
document.querySelector('#filepicker').addEventListener('change', async e => {
let files = Array.from(e.target.files)
let exifs = await Promise.all(files.map(exifr.parse))
let dates = exifs.map(exif => exif.DateTimeOriginal.toGMTString())
console.log(`${files.length} photos taken on:`, dates)
})
</script>
and a lot more in the examples/ folder
parse(file[, options])
Returns: Promise<object | undefined>
Accepts file (in any format), parses it and returns exif object. Optional options argument can be specified.
gps(file)
Returns: Promise<object | undefined>
Only extracts GPS coordinates.
Uses pick
/skip
filters and perf improvements to only extract latitude and longitude tags from GPS block. And to get GPS-IFD pointer it only scans through IFD0 without reading any other unrelated data.
Check out examples/gps.js to learn more.
orientation(file)
Returns: Promise<number | undefined>
Only extracts photo's orientation.
rotation(file)
Returns: Promise<object | undefined>
Only extracts photo's orientation. Returns object with instructions how to rotate the image:
deg
<number>
: angle in degrees (i.e. 180
), useful for css transform: rotate()
rad
<number>
: angle in radians (i.e. 3.141592653589793
) useful for canvas' ctx.rotate()
scaleX
<number>
: image is (-1
) or isn't (1
) mirrored horizontallyscaleY
<number>
: image is (-1
) or isn't (1
) mirrored upside downdimensionSwapped
<boolean>
: image is rotated by 90° or 270°. Fixing rotation would swap width
and height
.css
<boolean>
: can/can't be rotated with CSS and transform: rotate()
(important for ios Safari)canvas
<boolean>
: can/can't be rotated with canvas and ctx.rotate()
(important for ios Safari)Warning: Some modern browsers autorotate <img>
elements, background-image
and/or data passed to <canvas>
without altering the EXIF. The behavior is extra quirky on iOs 13.4 Safari and newer (though not on macos). You may end up with over-rotated image if you don't handle this quirk. See examples/orientation.html to learn more.
let r = await exifr.rotation(image)
if (r.css) {
img.style.transform = `rotate(${r.deg}deg) scale(${r.scaleX}, ${r.scaleY})`
}
thumbnail(file)
Returns: Promise<Buffer | Uint8Array | undefined>
Extracts embedded thumbnail from the photo, returns Uint8Array
.
Only parses as little EXIF as necessary to find offset of the thumbnail.
Check out examples/thumbnail.html and examples/thumbnail.js to learn more.
thumbnailUrl(file)
Returns: Promise<string | undefined>
browser only
Exports the thumbnail wrapped in Object URL. The URL has to be revoked when not needed anymore.
sidecar(file[, options[, type]])
Returns: Promise<object | undefined>
full bundle only
Parses sidecar file, i.e., an external metadata usually accompanied by the image file. Most notably .xmp
or .icc
.
Third argument is optional but advised if you know the segment type you're dealing with and want to improve performance. Otherwise exifr tries to infer the type from file extension (if file
is path or url) and/or randomly tries all parsers at its disposal.
exifr.sidecar('./img_1234.icc')
exifr.sidecar('./img_1234.icc', {translateKeys: false})
exifr.sidecar('./img_1234.colorprofile', {translateKeys: false}, 'icc')
Exifr
classAforementioned functions are wrappers that internally:
1) instantiate new Exifr(options)
class
2) call .read(file)
to load the file
3) call .parse()
or .extractThumbnail()
to get an output
You can instantiate Exif
yourself to parse metadata and extract thumbnail efficiently at the same time. In Node.js it's also necessary to close the file with .file.close()
if it's read in the chunked mode.
let exr = new Exifr(options)
await exr.read(file)
let output = await exr.parse()
let buffer = await exr.extractThumbnail()
await exr.file?.close?.()
file
argumentstring
Buffer
ArrayBuffer
Uint8Array
DataView
Blob
, File
<img>
elementoptions
argumentarray
of tags to parse, shortcut for options.pick
true
shortcut to parse all segments and blocksobject
with granular settingsAll other and undefined properties are inherited from defaults:
let defaultOptions = {
// Segments (JPEG APP Segment, PNG Chunks, HEIC Boxes, etc...)
tiff: true,
xmp: false,
icc: false,
iptc: false,
jfif: false, // (jpeg only)
ihdr: false, // (png only)
// Sub-blocks inside TIFF segment
ifd0: true, // aka image
ifd1: false, // aka thumbnail
exif: true,
gps: true,
interop: false,
// Other TIFF tags
makerNote: false,
userComment: false,
// Filters
skip: [],
pick: [],
// Formatters
translateKeys: true,
translateValues: true,
reviveValues: true,
sanitize: true,
mergeOutput: true,
silentErrors: true,
// Chunked reader
chunked: true,
firstChunkSize: undefined,
firstChunkSizeNode: 512,
firstChunkSizeBrowser: 65536, // 64kb
chunkSize: 65536, // 64kb
chunkLimit: 5,
httpHeaders: {},
}
Exifr can avoid reading certain tags, instead of reading but not including them in the output, like other exif libs do. For example MakerNote tag from EXIF block is isually very large - tens of KBs. Reading such tag is a waste of time if you don't need it.
Tip: Using numeric tag codes is even faster than string names because exifr doesn't have to look up the strings in dictionaries.
options.pick
Type: Array<string|number>
Array of the only tags that will be parsed.
Specified tags are looked up in a dictionary. Their respective blocks are enabled for parsing, all other blocks are disabled. Parsing ends as soon as all requested tags are extracted.
// Only extracts three tags from EXIF block. IFD0, GPS and other blocks disabled.
{pick: ['ExposureTime', 'FNumber', 'ISO']}
// Only extracts three tags from EXIF block and one tag from GPS block.
{pick: ['ExposureTime', 'FNumber', 'ISO', 'GPSLatitude']}
// Extracts two tags from GPS block and all of IFD0 and EXIF blocks which are enabled by default.
{gps: {pick: ['GPSLatitude', 0x0004]}}
options.skip
Type: Array<string|number>
Default: ['MakerNote', 'UserComments']
Array of the tags that will not be parsed.
By default, MakerNote and UserComment tags are skipped. But that is configured elsewhere.
// Skips reading these three tags in any block
{skip: ['ImageWidth', 'Model', 'FNumber', 'GPSLatitude']}
// Skips reading three tags in EXIF block
{exif: {skip: ['ImageUniqueID', 42033, 'SubSecTimeDigitized']}}
EXIF became synonymous for all image metadata, but it's actually just one of many blocks inside TIFF segment. And there are more segment than just TIFF.
Jpeg stores various formats of data in APP-Segments. Heic and Tiff file formats use different structures or naming conventions but the idea is the same, so we refer to TIFF, XMP, IPTC, ICC and JFIF as Segments.
options.tiff
type bool|object|Array
default: true
options.jfif
type bool
default: false
options.xmp
type bool
default: false
options.iptc
type bool
default: false
options.icc
type bool
default: false
options.ihdr
type bool
default: true
(only for PNG)
TIFF Segment consists of various IFD's (Image File Directories) aka blocks.
options.ifd0
(alias options.image
) type bool|object|Array
default: true
options.ifd1
(alias options.thumbnail
) type bool|object|Array
default: false
options.exif
type bool|object|Array
default: true
options.gps
type bool|object|Array
default: true
options.interop
type bool|object|Array
default: false
Notable large tags from EXIF block that are not parsed by default but can be enabed if needed.
options.makerNote
type: bool
default: false
options.userComment
type: bool
default: false
Extracted XMP tags are grouped by namespace. Each ns is separate object in output
. E.g. output.xmlns
, output.GPano
, output.crs
, etc...
For XMP Extended see options.multiSegment
Exifr contains minimalistic opinionated XML parser for parsing data from XMP. It may not be 100% spec-compliant, because XMP is based on XML which cannot be translated 1:1 to JSON. The output is opinionated and may alter or simplify the data structure. If the XMP parser doesn't suit you, it can be disabled by setting options.xmp.parse
to false
. Then a raw XMP string will be available at output.xmp
.
1) Tags with both attributes and children-value are combined into object.
2) Arrays (RDF Containers) with single item are unwrapped. The single item is used in place of the array.
2) If options.mergeOutput:false
: Tags of tiff
namespace (<tiff:Model>
) are merged into output.ifd0
. Likewise exif
ns is merged into output.exif
.
<rdf:Description foo:name="Exifr">
<foo:author>Mike Kovařík</foo:author>
<foo:description xml:lang="en-us">Some string here</foo:description>
<foo:formats><rdf:Seq><rdf:li>jpeg</rdf:li></rdf:Seq></foo:formats>
<foo:segments><rdf:Seq><rdf:li>xmp</rdf:li><rdf:li>tiff</rdf:li><rdf:li>iptc</rdf:li></rdf:Seq></foo:segments>
</rdf:Description>
parses as:
{
name: 'Exifr', // attribute belonging to the same namespace
author: 'Mike Kovařík', // simple tag of the namespace
description: {lang: 'en-us', value: 'Some string here'}, // tag with attrs and value becomes object
formats: 'jpeg', // single item array is unwrapped
segments: ['xmp', 'tiff', 'iptc'] // array as usual
}
options.multiSegment
Type: bool
Default: false
Enables looking for more than just a single segment of ICC or XMP (XMP Extended).
In some rare cases the photo can contain additional layers, embedded images, or metadata that doesn't fit inside single 64kB (JPEG) segment.
Side effect: Disables chunked reading. The whole file has to be read to locate all segments.
When is it useful:
Sub-options:
options.xmp.multiSegment
options.icc.multiSegment
options.tiff
serves as a shortcut for configuring all TIFF blocks:
options.tiff = true
enables all TIFF blocks (sets them to true
).options.tiff = false
disables all TIFF blocks (sets them to false
) except for those explicitly set to true
in options
.options.tiff = {...}
applies the same sub-options to all TIFF blocks that are enabled.options.tiff = false
can be paired with any other block(s) to disable all other blocks except for said block.
{interop: true, tiff: false}
// is a shortcut for
{interop: true, ifd0: false, exif: false, gps: false, ifd1: true}
Each TIFF block and the whole tiff
segment can also be configured with object
or array
, much like the options
argument.
object
- enabled with custom options - filters (pick
, skip
) and formatters (translateKeys
, translateValues
, reviveValues
)array
- enabled, but only picks tags from this arrayTIFF blocks automatically inherit from options.tiff
and then from options
.
// Only extract FNumber + ISO tags from EXIF and GPSLatitude + GPSLongitude from GPS
{
exif: true, gps: true,
pick: ['FNumber', 'ISO', 'GPSLatitude', 0x0004] // 0x0004 is GPSLongitude
}
// is a shortcut for
{exif: ['FNumber', 'ISO'], gps: ['GPSLatitude', 0x0004]}
// which is another shortcut for
{exif: {pick: ['FNumber', 'ISO']}, gps: {pick: ['GPSLatitude', 0x0004]}}
options.chunked
Type: bool
Default: true
Exifr can read only a few chunks instead of the whole file. It's much faster, saves memory and unnecessary disk reads or network fetches. Works great with complicated file structures - .tif files may point to metadata scattered throughout the file.
How it works: A first small chunk (of firstChunkSize
) is read to determine if the file contains any metadata at all. If so, reading subsequent chunks (of chunkSize
) continues until all requested segments are found or until chunkLimit
is reached.
Supported inputs: Chunked is only effective with Blob
, <img>
element, string
url, disk path, or base64. These inputs are not yet processed or read into memory. Each input format is implemented in a separate file reader class. Learn more about file readers and modularity here.
If you use URL as input: Fetching chunks (implemented in UrlFetcher
) from web server uses HTTP Range Requests. Range request may fail if your server does not support ranges, if it's not configured properly or if the fetched file is smaller than the first chunk size. Test your web server or disable chunked reader with {chunked: false}
when in doubt.
options.firstChunkSize
Type: number
Default: 512
Bytes in Node / 65536
(64 KB) in browser
Size (in bytes) of the first chunk that probes the file for traces of exif or metadata.
In browser, it's usually better to read just a larger chunk in hope that it contains the whole EXIF (and not just the beginning) instead of loading multiple subsequent chunks. Whereas in Node.js it's preferable to read as little data as possible and fs.read()
does not cause slowdowns.
options.chunkSize
Type: number
Default: 65536
Bytes (64 KB)
Size of subsequent chunks that may be read after the first chunk.
options.chunkLimit
Type: number
Default: 5
Max amount of subsequent chunks allowed to read in which exifr searches for data segments and blocks. I.e. failsafe that prevents from reading the whole file if it does not contain all of the segments or blocks requested in options
.
This limit is bypassed if multi-segment segments ocurs in the file and if options.multiSegment
allows reading all of them.
If the exif isn't found within N chunks (64*5 = 320KB) it probably isn't in the file and it's not worth reading anymore.
options.httpHeaders
Type: object
Default: {}
Additional HTTP headers to include when fetching chunks from URLs that require Authorization or other custom headers.
options.mergeOutput
Type: bool
Default: true
Merges all parsed segments and blocks into a single object.
Warning: mergeOutput: false
should not be used with translateKeys: false
or when parsing both ifd0
(image) and ifd1
(thumbnail). Tag keys are numeric, sometimes identical and may collide.
mergeOutput: true | mergeOutput: false |
{ Make: 'Google', Model: 'Pixel', FNumber: 2, Country: 'Czech Republic', xmp: '<x:xmpmeta><rdf:Description>...' } | { ifd0: { Make: 'Google', Model: 'Pixel' }, exif: { FNumber: 2 }, iptc: { Country: 'Czech Republic' }, xmp: '<x:xmpmeta><rdf:Description>...' } |
options.sanitize
Type: bool
Default: true
Cleans up unnecessary, untransformed or internal tags (IFD pointers) from the output.
options.silentErrors
Type: bool
Default: true
Suppresses errors that occur during parsing. Messages are stored at output.errors
instead of throwing and causing promise rejection.
NOTE: Some fundamental error's can still be thrown. Such as wrong arguments or Unknown file format
.
Failing silently enables reading broken files. But only file-structure related errors are caught.
options.translateKeys
Type: bool
Default: true
Translates tag keys from numeric codes to understandable string names. I.e. uses Model
instead of 0x0110
.
Most keys are numeric. To access the Model
tag use output.ifd0[0x0110]
or output.ifd0[272]
Learn more about dictionaries.
Warning: translateKeys: false
should not be used with mergeOutput: false
. Keys may collide because ICC, IPTC and TIFF segments use numeric keys starting at 0.
translateKeys: false | translateKeys: true |
{ 0x0110: 'Pixel', // IFD0 90: 'Vsetín', // IPTC 64: 'Perceptual', // ICC desc: 'sRGB IEC61966-2.1', // ICC } | { Model: 'Pixel', // IFD0 City: 'Vsetín', // IPTC RenderingIntent: 'Perceptual', // ICC ProfileDescription: 'sRGB IEC61966-2.1', // ICC } |
options.translateValues
Type: bool
Default: true
Translates tag values from raw enums to understandable strings. Learn more about dictionaries.
translateValues: false | translateValues: true |
{ Orientation: 1, ResolutionUnit: 2, DeviceManufacturer: 'GOOG' } | { Orientation: 'Horizontal (normal)', ResolutionUnit: 'inches', DeviceManufacturer: 'Google' } |
options.reviveValues
Type: bool
Default: true
Converts dates from strings to a Date instances and modifies few other tags to a more readable format. Learn more about dictionaries.
reviveValues: false | reviveValues: true |
{ GPSVersionID: [0x02, 0x02, 0x00, 0x00], ModifyDate: '2018:07:25 16:34:23', } | { GPSVersionID: '2.2.0.0', ModifyDate: <Date instance: 2018-07-25T14:34:23.000Z>, } |
Tips for advanced users. You don't need to read further unless you're into customization and bundlers.
fs
module. The import is obviously only used in Node.js and not triggered in a browser. But your bundler may, however, pick up on it and fail with something like Error: Can't resolve 'fs'
.
Parcel works out of the box and Webpack should too because of webpackIgnore
magic comment added to the library's source code import(/* webpackIgnore: true */ 'fs')
.
If this does not work for you, try adding node: {fs: 'empty'}
and target: 'web'
or target: 'webworker'
to your Webpack config. Or similar settings for your bundler of choice.
Alternatively, create your own bundle around core
build and do not include FsReader
in it.
Exifr is written using modern syntax, mainly async/await. You may need to add regenerator-runtime
or reconfigure babel.
Here are a few tips for when you need to squeeze an extra bit of speed out of exifr when processing a large amount of files. Click to expand.
options.pick
if you only need certain tagsoptions.ifd0
if you don't need the dataexifr.gps()
if you only need GPSexifr.gps()
because it is fine-tuned to do exactly this and nothing more. Similarly there's exifr.orientation()
.
```js
// do this:
exifr.gps(file)
// not this:
exifr.parse(file, {gps: true})
```
options
objectoptions
object instead of inlining it. Exifr uses your options
to create an instance of Options
class under the hood and uses WeakMap
to find previously created instance instead of creating q new one each time.
```js
// do this:
let options = {exif: true, iptc: true}
for (let file of files) exif.parse(file, options)
// not this:
for (let file of files) exif.parse(file, {exif: true, iptc: true})
```
File reading: You don't need to read the whole file and parse through a MBs of data. Exifr takes an educated guess to only read a small chunk of the file where metadata is usually located. Each platform, file format, and data type is approached differently to ensure the best performance.
Finding metadata: Other libraries use brute force to read through all bytes until 'Exif'
string is found. Whereas exifr recognizes the file structure, consisting of segments (JPEG) or nested boxes (HEIC). This allows exifr to read just a few bytes here and there, to get the offset and size of the segment/box and pointers to jump to the next.
HEIC: Simply finding the exif offset takes 0.2-0.3ms with exifr. Compare that to exif-heic-js which takes about 5-10ms on average. Exifr is up to 30x faster.
pigallery2 did a few benchmarks.
2036 photos (in total 22GB):
lib | average | all files
---------------------------------
exifr | 2.5ms | 5s <--- !!!
exifreader | 9.5ms | 19.5s
exiftool | 76ms | 154s
Try the benchmark yourself at benchmark/chunked-vs-whole.js
user reads file 8.4 ms
exifr reads whole file 8.2 ms
exifr reads file by chunks 0.5 ms <--- !!!
only parsing, not reading 0.2 ms <--- !!!
Observations from testing with +-4MB pictures (Highest quality Google Pixel photos. Tested on a mid-range dual-core i5 machine with SSD).
fs.readFile
= 0.3msArrayBuffer
= 3msBlob
= 7ms<img>
with Object URL = 3msBe sure to visit the exifr playground or benchmark/gps-dnd.html, drop in your photos and watch the parsed in timer.
For full changelog visit CHANGELOG.md
.
string
URLs as file
argument are now accepted in Node.js (UrlFetcher
uses polyfill for fetch()
in Node.js). But only in full
bundle.
rdf:Description
, but breaking change nonetheless."main"
now points to UMD bundle for better compatibility.legacy
builds. Suppporting IE10.Contributions are welcome in any form. Suggestions, bug reports, docs improvements, new tests or even feature PRs. Don't be shy, I don't bite.
If you're filing an issue, please include:
There are so many environments, tools and frameworks and I can't know, nor try them all out. Letting me peek into your setup makes tracking down the problem so much easier.
PRs are gladly accepted. Please run tests before you create one:
/test/index.html
(uses import maps, you may need to enable experimental flags in your browser)npm run test
MIT, Mike Kovařík, Mutiny.cz