Orientation Exif côté client JS: rotation et miroir des images JPEG


154

Les photos des appareils photo numériques sont souvent enregistrées au format JPEG avec une balise «d'orientation» EXIF. Pour s'afficher correctement, les images doivent être pivotées / reflétées en fonction de l'orientation définie, mais les navigateurs ignorent ces informations pour rendre l'image. Même dans les grandes applications Web commerciales, la prise en charge de l'orientation EXIF ​​peut être irrégulière 1 . La même source fournit également un joli résumé des 8 orientations différentes qu'un JPEG peut avoir:

Résumé des orientations EXIF

Des exemples d'images sont disponibles à l'adresse 4 .

La question est de savoir comment faire pivoter / refléter l'image du côté client afin qu'elle s'affiche correctement et puisse être traitée ultérieurement si nécessaire.

Il existe des bibliothèques JS disponibles pour analyser les données EXIF, y compris l'attribut d'orientation 2 . Flickr a noté un problème de performance possible lors de l'analyse de grandes images, nécessitant l'utilisation de webworkers 3 .

Les outils de la console peuvent réorienter correctement les images 5 . Un script PHP résolvant le problème est disponible à 6

Réponses:


145

Le projet github JavaScript-Load-Image fournit une solution complète au problème d'orientation EXIF, en faisant correctement pivoter / refléter les images pour les 8 orientations exif. Voir la démo en ligne de l' orientation javascript exif

L'image est dessinée sur un canevas HTML5. Son rendu correct est implémenté dans js / load-image-orientation.js via des opérations de canevas.

J'espère que cela fera gagner du temps à quelqu'un d'autre et enseignera aux moteurs de recherche ce joyau open source :)


1
J'utilise cette bibliothèque mais récemment, elle est tombée en panne sur iOS 8.3, c'est là que j'en ai le plus besoin pour fonctionner :(
Gordon Sun

2
J'ai vraiment du mal à voir en quoi cela est utile. Je joue avec pour essayer de l'apprendre afin que je puisse soit analyser une page et faire pivoter les images lorsqu'elles doivent être pivotées, soit détecter l'orientation et faire pivoter le fichier (d'une manière ou d'une autre) avant de le télécharger. La démo ne fait ni l'un ni l'autre. Il prend simplement un fichier à partir d'une entrée de fichier et l'affiche de la bonne manière, quand est-ce utile dans le monde réel? Lorsque j'analyse ma page et alimente les URL des balises d'image dans la bibliothèque loadImage, il n'y a pas de données exif, donc je ne peux pas le faire. Pour le téléchargement, il renvoie un objet canevas donc je ne peux pas l'envoyer au serveur ou quoi que ce soit.
igneosaur

3
@igneosaur: vous pouvez envoyer des données encodées en base64 au serveur avec canvas.toDataURL(), les décoder et les enregistrer côté serveur.
br4nnigan

1
plutôt que d'utiliser le projet load-image, vous pouvez utiliser une partie de sa base de code pour créer la vôtre - il suffit de regarder dans github.com/blueimp/JavaScript-Load-Image/blob/master/js/… .
Andy Lorenz

1
Existe-t-il un moyen de rendre le canevas que cette bibliothèque produit réactif comme une <img>balise ordinaire ?
Erik Berkun-Drevnig le

103

La transformation de contexte de Mederr fonctionne parfaitement. Si vous avez besoin d'extraire l'orientation, utilisez uniquement cette fonction - vous n'avez pas besoin de bibliothèques de lecture EXIF. Vous trouverez ci-dessous une fonction pour réinitialiser l'orientation dans l'image base64. Voici un violon pour cela . J'ai également préparé une démo d'extraction de violon avec orientation .

function resetOrientation(srcBase64, srcOrientation, callback) {
  var img = new Image();    

  img.onload = function() {
    var width = img.width,
        height = img.height,
        canvas = document.createElement('canvas'),
        ctx = canvas.getContext("2d");

    // set proper canvas dimensions before transform & export
    if (4 < srcOrientation && srcOrientation < 9) {
      canvas.width = height;
      canvas.height = width;
    } else {
      canvas.width = width;
      canvas.height = height;
    }

    // transform context before drawing image
    switch (srcOrientation) {
      case 2: ctx.transform(-1, 0, 0, 1, width, 0); break;
      case 3: ctx.transform(-1, 0, 0, -1, width, height); break;
      case 4: ctx.transform(1, 0, 0, -1, 0, height); break;
      case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
      case 6: ctx.transform(0, 1, -1, 0, height, 0); break;
      case 7: ctx.transform(0, -1, -1, 0, height, width); break;
      case 8: ctx.transform(0, -1, 1, 0, 0, width); break;
      default: break;
    }

    // draw image
    ctx.drawImage(img, 0, 0);

    // export base64
    callback(canvas.toDataURL());
  };

  img.src = srcBase64;
};

7
La casse par défaut dans l'orientation switchn'est pas nécessaire, car cette transformation ne fait rien. Pensez également à utiliser srcOrientation > 4 && srcOrientation < 9au lieu de [5,6,7,8].indexOf(srcOrientation) > -1, car il est plus rapide et moins gourmand en ressources (à la fois RAM et CPU). Il n'est pas nécessaire d'avoir un tableau là-bas. Ceci est important lors du groupage de lots d'images, où chaque bit compte. Sinon, très bonne réponse. Vote positif!
Ryan Casas

4
@RyanCasas Je ne savais pas à quel point cela pouvait indexOfêtre lourd par rapport à ce que vous proposiez. J'ai couru une boucle simple avec 10 millions d'itérations et c'était 1400% plus rapide. Nice: D Merci beaucoup!
WunderBart

1
J'ai utilisé cette réponse dans une WebView Android et il s'est avéré que certains appareils Android ne prennent pas en charge WebGL dans une WebView (voir bugs.chromium.org/p/chromium/issues/detail?id=555116 ). la rotation peut prendre très longtemps sur de tels appareils en fonction de la taille de l'image.
ndreisg

1
Lorsqu'il est utilisé avec le référencé getOrientation, je suis curieux de savoir si cela est efficace en termes de performances. getOrientationappels fileReader.readAsArrayBuffer, puis nous appelons URL.createObjectURLet transmettons le résultat à resetOrientation, qui charge cette URL en tant qu'image. Cela signifie-t-il que le fichier image sera "chargé" / lu par le navigateur non pas une mais deux fois, ou ai-je mal compris?
Oliver Joseph Ash

1
Aussi, que faire des navigateurs où l'orientation sera déjà respectée? Je pense que c'est le cas pour iOS Safari, et dans les navigateurs prenant en charge CSS image-orientation: from-imagelorsqu'il est utilisé.
Oliver Joseph Ash

39

Si

width = img.width;
height = img.height;
var ctx = canvas.getContext('2d');

Ensuite, vous pouvez utiliser ces transformations pour transformer l'image en orientation 1

De l'orientation:

  1. ctx.transform(1, 0, 0, 1, 0, 0);
  2. ctx.transform(-1, 0, 0, 1, width, 0);
  3. ctx.transform(-1, 0, 0, -1, width, height);
  4. ctx.transform(1, 0, 0, -1, 0, height);
  5. ctx.transform(0, 1, 1, 0, 0, 0);
  6. ctx.transform(0, 1, -1, 0, height, 0);
  7. ctx.transform(0, -1, -1, 0, height, width);
  8. ctx.transform(0, -1, 1, 0, 0, width);

Avant de dessiner l'image sur ctx


58
que se passe-t-il même ici?
Muhammad Umer

1
Avec cela, il vous suffit de connaître l'orientation (par exemple via EXIF), puis de faire pivoter si nécessaire. La moitié de ce que je recherche.
Brenden

2
ces transformations n'ont pas fonctionné pour moi - à la place, j'ai utilisé le code du projet load-image sur github.com/blueimp/JavaScript-Load-Image/blob/master/js/... pour obtenir ce que je pense être un code éprouvé qui utilise à la fois des opérations de traduction et de rotation sur le contexte du canevas plus un échange largeur / hauteur. M'a donné des résultats à 100% après de nombreuses heures d'expérimentation avec d'autres tentatives de transformation, etc.
Andy Lorenz

1
Pourquoi est-ce important lorsque vous dessinez l'image sur ctx? Vous ne pouvez pas faire pivoter la toile après avoir dessiné une image dessus?
AlxVallejo

La rotation pour l'orientation 8 fonctionne de cette façon pour moi: ctx.transform (0, -1, 1, 0, 0, hauteur);
Giorgio Barchiesi

24

ok en plus de la réponse @ user3096626, je pense que ce sera plus utile si quelqu'un a fourni un exemple de code, l'exemple suivant vous montrera comment corriger l'orientation de l'image provient de l'url (images distantes):


Solution 1: utiliser javascript (recommandé)

  1. car la bibliothèque load-image n'extrait pas les balises exif des images url uniquement (fichier / blob), nous utiliserons à la fois les bibliothèques javascript exif-js et load-image , alors ajoutez d'abord ces bibliothèques à votre page comme suit:

    <script src="https://cdnjs.cloudflare.com/ajax/libs/exif-js/2.1.0/exif.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-load-image/2.12.2/load-image.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-load-image/2.12.2/load-image-scale.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-load-image/2.12.2/load-image-orientation.min.js"></script>

    Notez que la version 2.2 d'exif-js semble avoir des problèmes, nous avons donc utilisé 2.1

  2. alors fondamentalement ce que nous ferons est

    a - charger l'image en utilisant window.loadImage()

    b - lire les balises exif en utilisant window.EXIF.getData()

    c - convertir l'image en canevas et fixer l'orientation de l'image en utilisant window.loadImage.scale()

    d - placez le canevas dans le document

Voici :)

window.loadImage("/your-image.jpg", function (img) {
  if (img.type === "error") {
    console.log("couldn't load image:", img);
  } else {
    window.EXIF.getData(img, function () {
        var orientation = EXIF.getTag(this, "Orientation");
        var canvas = window.loadImage.scale(img, {orientation: orientation || 0, canvas: true});
        document.getElementById("container").appendChild(canvas); 
        // or using jquery $("#container").append(canvas);

    });
  }
});

bien sûr, vous pouvez également obtenir l'image en base64 à partir de l'objet canvas et la placer dans l'attribut img src, donc en utilisant jQuery, vous pouvez le faire;)

$("#my-image").attr("src",canvas.toDataURL());

voici le code complet sur: github: https://github.com/digital-flowers/loadimage-exif-example


Solution 2: utiliser HTML (piratage de navigateur)

il y a un hack très rapide et facile, la plupart des navigateurs affichent l'image dans la bonne orientation si l'image est ouverte dans un nouvel onglet directement sans aucun html (LOL je ne sais pas pourquoi), donc en gros, vous pouvez afficher votre image en utilisant iframe en mettant directement l'attribut iframe src comme URL de l'image:

<iframe src="/my-image.jpg"></iframe>

Solution 3: utiliser le CSS (uniquement Firefox et Safari sur iOS)

il y a un attribut css3 pour corriger l'orientation de l'image, mais le problème, il ne fonctionne que sur Firefox et Safari / ios, cela vaut toujours la peine d'être mentionné car bientôt il sera disponible pour tous les navigateurs (Informations sur le support du navigateur de caniuse )

img {
   image-orientation: from-image;
}

La solution 1 n'a pas fonctionné pour moi. Il est de retour window.loadImage is undefined
Perry

Cela s'est produit si vous n'avez pas inclus les bibliothèques que j'ai mentionnées à l'étape 1
Fareed Alnamrouti

J'ai inclus les bibliothèques. J'aime la simplicité de votre exemple mais je reçois toujours ce message d'erreur.
Perry

1
il semble que exif-js était cassé, veuillez vérifier ma modification.J'ai également ajouté le code complet sur github: github.com/digital-flowers/loadimage-exif-example
Tarif Alnamrouti

1
ok checkout le projet github il y a un nouveau fichier "upload.html", vous êtes les bienvenus :)
Fareed Alnamrouti

10

Pour ceux qui ont un fichier d'un contrôle d'entrée, ne savent pas quelle est son orientation, sont un peu paresseux et ne veulent pas inclure une grande bibliothèque ci-dessous est le code fourni par @WunderBart fusionné avec la réponse à laquelle il renvoie ( https://stackoverflow.com/a/32490603 ) qui trouve l'orientation.

function getDataUrl(file, callback2) {
        var callback = function (srcOrientation) {
            var reader2 = new FileReader();
            reader2.onload = function (e) {
                var srcBase64 = e.target.result;
                var img = new Image();

                img.onload = function () {
                    var width = img.width,
                        height = img.height,
                        canvas = document.createElement('canvas'),
                        ctx = canvas.getContext("2d");

                    // set proper canvas dimensions before transform & export
                    if (4 < srcOrientation && srcOrientation < 9) {
                        canvas.width = height;
                        canvas.height = width;
                    } else {
                        canvas.width = width;
                        canvas.height = height;
                    }

                    // transform context before drawing image
                    switch (srcOrientation) {
                        case 2: ctx.transform(-1, 0, 0, 1, width, 0); break;
                        case 3: ctx.transform(-1, 0, 0, -1, width, height); break;
                        case 4: ctx.transform(1, 0, 0, -1, 0, height); break;
                        case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
                        case 6: ctx.transform(0, 1, -1, 0, height, 0); break;
                        case 7: ctx.transform(0, -1, -1, 0, height, width); break;
                        case 8: ctx.transform(0, -1, 1, 0, 0, width); break;
                        default: break;
                    }

                    // draw image
                    ctx.drawImage(img, 0, 0);

                    // export base64
                    callback2(canvas.toDataURL());
                };

                img.src = srcBase64;
            }

            reader2.readAsDataURL(file);
        }

        var reader = new FileReader();
        reader.onload = function (e) {

            var view = new DataView(e.target.result);
            if (view.getUint16(0, false) != 0xFFD8) return callback(-2);
            var length = view.byteLength, offset = 2;
            while (offset < length) {
                var marker = view.getUint16(offset, false);
                offset += 2;
                if (marker == 0xFFE1) {
                    if (view.getUint32(offset += 2, false) != 0x45786966) return callback(-1);
                    var little = view.getUint16(offset += 6, false) == 0x4949;
                    offset += view.getUint32(offset + 4, little);
                    var tags = view.getUint16(offset, little);
                    offset += 2;
                    for (var i = 0; i < tags; i++)
                        if (view.getUint16(offset + (i * 12), little) == 0x0112)
                            return callback(view.getUint16(offset + (i * 12) + 8, little));
                }
                else if ((marker & 0xFF00) != 0xFF00) break;
                else offset += view.getUint16(offset, false);
            }
            return callback(-1);
        };
        reader.readAsArrayBuffer(file);
    }

qui peut facilement être appelé comme tel

getDataUrl(input.files[0], function (imgBase64) {
      vm.user.BioPhoto = imgBase64;
});

2
Merci après plus de 2 heures de recherche par google, je trouve la bonne solution.
Mr.Trieu

2
pourquoi quelqu'un serait-il paresseux pour préférer une solution beaucoup plus légère?
rosée du

4
Porté sur Typescript et optimisé d'environ 4 secondes à ~ 20 millisecondes. L'encodage Base64 était le goulot d'étranglement - l'utilisation image/jpegau lieu de la valeur par défaut image/pnget le redimensionnement de l'image de sortie à une largeur maximale (400 px dans mon cas) a fait une énorme différence. (cela fonctionne bien pour les vignettes, mais si vous devez afficher l'image complète, je vous suggère d'éviter base64 et d'injecter simplement l'élément canvas directement dans la page ...)
mindplay.dk

8

La réponse de WunderBart était la meilleure pour moi. Notez que vous pouvez l'accélérer beaucoup si vos images sont souvent dans le bon sens, simplement en testant l'orientation d'abord et en contournant le reste du code si aucune rotation n'est requise.

Rassembler toutes les informations de wunderbart, quelque chose comme ça;

var handleTakePhoto = function () {
    let fileInput: HTMLInputElement = <HTMLInputElement>document.getElementById('photoInput');
    fileInput.addEventListener('change', (e: any) => handleInputUpdated(fileInput, e.target.files));
    fileInput.click();
}

var handleInputUpdated = function (fileInput: HTMLInputElement, fileList) {
    let file = null;

    if (fileList.length > 0 && fileList[0].type.match(/^image\//)) {
        isLoading(true);
        file = fileList[0];
        getOrientation(file, function (orientation) {
            if (orientation == 1) {
                imageBinary(URL.createObjectURL(file));
                isLoading(false);
            }
            else 
            {
                resetOrientation(URL.createObjectURL(file), orientation, function (resetBase64Image) {
                    imageBinary(resetBase64Image);
                    isLoading(false);
                });
            }
        });
    }

    fileInput.removeEventListener('change');
}


// from http://stackoverflow.com/a/32490603
export function getOrientation(file, callback) {
    var reader = new FileReader();

    reader.onload = function (event: any) {
        var view = new DataView(event.target.result);

        if (view.getUint16(0, false) != 0xFFD8) return callback(-2);

        var length = view.byteLength,
            offset = 2;

        while (offset < length) {
            var marker = view.getUint16(offset, false);
            offset += 2;

            if (marker == 0xFFE1) {
                if (view.getUint32(offset += 2, false) != 0x45786966) {
                    return callback(-1);
                }
                var little = view.getUint16(offset += 6, false) == 0x4949;
                offset += view.getUint32(offset + 4, little);
                var tags = view.getUint16(offset, little);
                offset += 2;

                for (var i = 0; i < tags; i++)
                    if (view.getUint16(offset + (i * 12), little) == 0x0112)
                        return callback(view.getUint16(offset + (i * 12) + 8, little));
            }
            else if ((marker & 0xFF00) != 0xFF00) break;
            else offset += view.getUint16(offset, false);
        }
        return callback(-1);
    };

    reader.readAsArrayBuffer(file.slice(0, 64 * 1024));
};

export function resetOrientation(srcBase64, srcOrientation, callback) {
    var img = new Image();

    img.onload = function () {
        var width = img.width,
            height = img.height,
            canvas = document.createElement('canvas'),
            ctx = canvas.getContext("2d");

        // set proper canvas dimensions before transform & export
        if (4 < srcOrientation && srcOrientation < 9) {
            canvas.width = height;
            canvas.height = width;
        } else {
            canvas.width = width;
            canvas.height = height;
        }

        // transform context before drawing image
        switch (srcOrientation) {
            case 2: ctx.transform(-1, 0, 0, 1, width, 0); break;
            case 3: ctx.transform(-1, 0, 0, -1, width, height); break;
            case 4: ctx.transform(1, 0, 0, -1, 0, height); break;
            case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
            case 6: ctx.transform(0, 1, -1, 0, height, 0); break;
            case 7: ctx.transform(0, -1, -1, 0, height, width); break;
            case 8: ctx.transform(0, -1, 1, 0, 0, width); break;
            default: break;
        }

        // draw image
        ctx.drawImage(img, 0, 0);

        // export base64
        callback(canvas.toDataURL());
    };

    img.src = srcBase64;
}

1
Excellente approche. Vous devez également envisager de gérer le -2et le -1retour de getOrientationafin de ne pas essayer de faire pivoter des images ou des images non jpgs sans données de rotation.
Steven Lambert le

J'ai fait plus d'optimisations, voir mon commentaire ci - dessus - je viens d'ajouter votre file.slice()optimisation également, mais le vrai coup de pouce vient de l'utilisation d'images plus petites et d'un image/jpegformat de sortie pour créer des URL de données plus petites. (il n'y a pas de retard notable, même pour les images de 10 mégapixels.)
mindplay.dk

Attention file.slice(), car vous éviterez la prise en charge des sources d'images base64.
Mark

Merci Mark - Voulez-vous fournir une modification pour une alternative?
statler

7

Un seul paquebot?

Je n'ai vu personne mentionner la browser-image-compressionbibliothèque . Il a une fonction d'assistance parfaite pour cela.

Usage: const orientation = await imageCompression.getExifOrientation(file)

Un outil aussi utile à bien d'autres égards.


Cela obtient l'orientation. Mais comment produire un nouvel objet Fichier .jpg côté client en utilisant ces informations?
masterxilo

Vous ne pouvez pas créer d' Fileobjets pour autant que je sache. La meilleure chose à faire serait ensuite d'envoyer l'image et l'orientation au serveur, et d'y faire des manipulations de fichiers. Ou vous pouvez générer une chaîne base64 en utilisant canvaset la toDataURLméthode.
gilbert-v

1
Non, vous pouvez très bien créer un objet File à partir d'un Blob. Fichier fichier (parties du tableau, nom de fichier de chaîne, propriétés BlobPropertyBag); developer.mozilla.org/de/docs/Web/API/File
masterxilo

2
Autocollant d'or! @masterxilo
gilbert-v

2
Cela a très bien fonctionné pour moi! J'avais du mal avec les orientations depuis 2 jours! Toutes les instructions de commutation de transformation qui ont été publiées ne géreraient pas les images de portrait de mon téléphone pour une raison quelconque, il y aurait des barres noires. Probablement parce que j'essayais de compresser et de tourner en même temps.
cjd82187


1

J'utilise une solution mixte (php + css).

Des conteneurs sont nécessaires pour:

  • div.imgCont2 conteneur nécessaire pour tourner;
  • div.imgCont1conteneur nécessaire pour zoomer - width:150%;
  • div.imgCont conteneur nécessaire pour les barres de défilement, lorsque l'image est zoomOut.

.

<?php
    $image_url = 'your image url.jpg';
    $exif = @exif_read_data($image_url,0,true);
    $orientation = @$exif['IFD0']['Orientation'];
?>

<style>
.imgCont{
    width:100%;
    overflow:auto;
}
.imgCont2[data-orientation="8"]{
    transform:rotate(270deg);
    margin:15% 0;
}
.imgCont2[data-orientation="6"]{
    transform:rotate(90deg);
    margin:15% 0;
}
.imgCont2[data-orientation="3"]{
    transform:rotate(180deg);
}
img{
    width:100%;
}
</style>

<div class="imgCont">
  <div class="imgCont1">
    <div class="imgCont2" data-orientation="<?php echo($orientation) ?>">
      <img src="<?php echo($image_url) ?>">
    </div>
  </div>
</div>

Merci, cela semble être la meilleure solution, pour mes besoins de toute façon. Pas besoin de bibliothèques externes et pas de longs blocs de code inutile. Déterminez simplement l'orientation de l'exif avec php, envoyez-le à la vue et utilisez-le pour déclencher des classes CSS qui tournent le nombre approprié de degrés. Agréable et propre! Modifier: cela fera pivoter les images dans les navigateurs qui lisent réellement les données exif et pivotent en conséquence. AKA, cela résoudra le problème sur le bureau mais en créera un nouveau sur ios safari.
cwal

0

En plus de la réponse de @fareed namrouti,

Cela doit être utilisé si l'image doit être parcourue à partir d'un élément d'entrée de fichier

<input type="file" name="file" id="file-input"><br/>
image after transform: <br/>
<div id="container"></div>

<script>
    document.getElementById('file-input').onchange = function (e) {
        var image = e.target.files[0];
        window.loadImage(image, function (img) {
            if (img.type === "error") {
                console.log("couldn't load image:", img);
            } else {
                window.EXIF.getData(image, function () {
                    console.log("load image done!");
                    var orientation = window.EXIF.getTag(this, "Orientation");
                    var canvas = window.loadImage.scale(img,
                        {orientation: orientation || 0, canvas: true, maxWidth: 200});
                    document.getElementById("container").appendChild(canvas);
                    // or using jquery $("#container").append(canvas);
                });
            }
        });
    };
</script>

0

J'ai écrit un petit script php qui fait pivoter l'image. Assurez-vous de stocker l'image en faveur de la recalculer simplement à chaque demande.

<?php

header("Content-type: image/jpeg");
$img = 'IMG URL';

$exif = @exif_read_data($img,0,true);
$orientation = @$exif['IFD0']['Orientation'];
if($orientation == 7 || $orientation == 8) {
    $degrees = 90;
} elseif($orientation == 5 || $orientation == 6) {
    $degrees = 270;
} elseif($orientation == 3 || $orientation == 4) {
    $degrees = 180;
} else {
    $degrees = 0;
}
$rotate = imagerotate(imagecreatefromjpeg($img), $degrees, 0);
imagejpeg($rotate);
imagedestroy($rotate);

?>

À votre santé

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.