Kawałek Kodu

Oglądałeś programy ezoteryczne, uczestniczyłeś w seansie spirytystycznym, albo przewidywałeś kartkówkę w podstawówce? Szykuj biały obrus i kładź ręce na stół. To czym zajmiemy się dziś będzie graniczyć z umiejętnością programistycznej prekognicji. Będziemy zgadywać jakie wymiary ma zdjęcie bez wczytywania go! I to trafnie!

Czy to na pewno jasnowidzenie?

Jak standardowo możemy sprawdzić rozdzielczość zdjęcia na stronie www? Możemy upload'ować je na serwer i tam sprawdzić zwracając informacje, możemy też po stronie klienta oczekiwać na zdarzenie load obrazka i wtedy sprawdzić. Tyle, że często nie ma sensu wczytywać całego, dużego pliku, aby dopiero po tym dowiedzieć się tego, co chcemy wiedzieć właściwie na początku. Bo być może mamy ograniczenie wczytywania zdjęć o wymiarach przekraczających zadane. Informacje o wymiarach obrazkach są umieszczone często w nagłówku (kilku(dziesięciu) początkowych bajtach pliku). Wystarczy więc je wczytać, a znając strukturę danego formatu "wyciągnąć" te informacje na wierzch. Pomimo, że jednak wczytamy fragment obrazka, to te kilkadziesiąt bajtów, to kropla w morzu w stosunku do całego pliku. Będziemy badać pliki GIF, PNG i BMP. Dlaczego nie JPEG? Bo niestety w tym formacie nie ma bezpośredniej informacji o rozmiarze. Ta jest rozrzucona po całym pliku, tj. mamy informację o wymiarach różnych fragmentów - dopiero odczyt każdego z nich i sumowanie dałoby rzeczywiste wymiary. Czyli operacja traci sens, bo równie dobrze można wczytać cały obrazek.

Karty na stół.

Struktura poszczególnych formatów jest następująca:

format nagłówek (początkowe bajty) szerokość (offset od początku pliku) wysokość (offset od początku pliku) postać
GIF87a 0x47, 0x49, 0x46, 0x38, 0x39, 0x61 6-7 8-9 little endian
GIF89a 0x47, 0x49, 0x46, 0x38, 0x37, 0x61 6-7 8-9 little endian
PNG 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a 16-19 20-23 big endian
BMP 0x42, 0x4d 18-19 22-23 little endian

W plikach GIF oraz BMP każdy wymiar umieszczony jest na dwóch bajtach w postaci little endian (najpierw najmniej znaczący bajt), natomiast w pliku PNG każdy na 4 bajtach w postaci big endian (najpierw najbardziej znaczący bajt). O tym jeszcze będzie dalej.

Wydarzenie w salonie wróżb.

Aby dobrać się do naszego pliku musimy przede wszystkim dać użytkownikowi możliwość wyboru go z dysku. Oczywiście użyjemy do tego INPUT o typie file. Przy zdarzeniu change otrzymamy między innymi informacje o wadze pliku (ta również może się później przydać), ale i listę plików. Lista plików to obiekty File, a każdy z nich ma typ Blob. Ciekawe jest to, że nie możemy bezpośrednio czytać bajtów z obiektu File, choć możemy wyciąć jakiś fragment. Obiekt File (lub jego fragment) można czytać przekazując go do FileReader'a.

Zacznijmy od czegoś:

<input type="file" />

+

const input = document.querySelector('input[type="file"]');
/* dodajemy zdarzenie do naszego <INPUT> */
input.addEventListener("change", function() {
/* w files mamy listę plików
   wycinamy z pierwszego 24 bajty,
   bo w tym fragmencie mieszczą się
   nagłówki wymienionych formatów
   wraz z informacją o szerokości
   i wysokości
*/
  const blob = this.files[0].slice(0, 24);
  const size = this.files[0].size;

/* tworzymy obiekt FileReader */
  const reader = new FileReader();
/* po załadowaniu dane mamy w
   e.target.result
*/
  reader.addEventListener("loadend", function(e) {
/* z danych tworzymy tablicę bajtów
   bez znaku
*/
    let buffer = new Uint8Array(e.target.result);
  });
/* wymuszamy odczyt 24 bajtów */
  reader.readAsArrayBuffer(blob);
});

Fragment szklanej kuli.

Część działań mamy za sobą. Jesteśmy w punkcie kiedy odczytaliśmy 24 bajty z pliku, choć jeszcze nie wiemy co z nimi zrobić. Kierując się jednak powyższą tabelą możemy próbować coś zrobić. Zanim jednak za to się zabierzemy, wyjaśnię to co obiecałem wcześniej - kwestię big/little endian.

10000 w postaci heksadecymalnej to 186a0. Jeśli liczba ta będzie reprezentowana w pamięci za pomocą 4 bajtów (long word) w zapisie little endian, to będą to bajty: a0 86 01 00. Tak więc, aby przekonwertować odrębne bajty na liczbę musimy postępować jak poniżej:

i aktualna wartość bajt i*8 bajt<<(i*8) nowa wartość=aktualna wartość|bajt<<(i*8)
0 0 a0 0 a0 a0
1 a0 86 8 8600 86a0
2 86a0 01 16 010000 0186a0
3 0186a0 00 24 00000000 000186a0

Jeśli to pierwszy bajt, to nie przesuwamy go w lewo (<<0). Stanowić będzie najmniej znaczący bajt. Kolejny bajt przesuwamy o 8 bitów w lewo (<<8, czyli mnożymy przez 256) i sumujemy logicznie (or) z aktualną wartością. Mamy już 16 mniej znaczących bitów liczby. Kolejny bajt przesuwamy o 16 bitów i postępujemy podobnie, a ostatni o 24 bity i znów sumujemy logicznie.

W przypadku zapisu big endian, sytuacja jest prostsza. Bajty dostajemy w kolejności od bardziej znaczącego - 00 01 86 a0. Pobierając je postępujemy tak:

i aktualna wartość aktualna wartość<<8 bajt nowa wartość=aktualna wartość<<8|bajt
0 0 00 00 00
1 00 0000 01 0001
2 0001 000100 86 000186
3 000186 00018600 a0 000186a0

Czyli przed dołożeniem kolejnego bajtu, przesuwamy w lewo o 8 bitów to co już mamy.

Teraz możemy zabrać się za "rozszyfrowanie" nagłówków i dobranie się do wymiarów obrazka. Dodajemy tablicę typów plików wraz z "namiarami" na potrzebne informacje:

const fileTypes = {
    gif87: {
      header: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
      dimensions: { type: "le", width: [6, 7], height: [8, 9] }
    },
    gif89: {
      header: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
      dimensions: { type: "le", width: [6, 7], height: [8, 9] }
    },
    png: {
      header: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
      dimensions: {
        type: "be",
        width: [16, 17, 18, 19],
        height: [20, 21, 22, 23]
      }
    },
    bmp: {
      header: [0x42, 0x4d],
      dimensions: { type: "le", width: [18, 19], height: [22, 23] }
    }
  };

Mamy tu obiekt, w którym informacje o każdym pliku przechowujemy pod odrębnym kluczem (typem). W obiekcie pod danym kluczem mamy reprezentacją heksadecymalną bajtów nagłówka oraz informacje o wymiarach (pod jakimi bajtami i w jakiej postaci). Wykorzystajmy tą informację mając już dostęp do bufora FileReader'a. Zdarzenie loadend będzie wyglądać teraz tak:

reader.addEventListener("loadend", function(e) {
  let buffer = new Uint8Array(e.target.result);

  let type;
/* iterujemy po tablicy plików */
  for (type in fileTypes) {
/* inicjujemy licznik
   poprawnych bajtów
*/
    let proper = 0;
/* przeglądamy kolejne bajty z bufora
   i porównujemy z wzorcowym nagłówkiem,
   jeśli zgadza się, to zwiększamy licznik
   poprawnych bajtów
*/
    for (let j = 0; j < fileTypes[type]["header"].length; j++) {
      proper += buffer[j] === fileTypes[type]["header"][j];
    }
/* jeśli wszystkie bajty nagłówka
   są poprawne, to przerywamy,
   bo mamy szukany typ pliku
*/
    if (proper === fileTypes[type]["header"].length) {
      break;
    }
/* jeśli nie, to ustawiamy
   typ pliku na false
*/
    else {
      type = false;
    }
  }

/* jeśli znaleźliśmy typ pliku */
  if (type) {

/* początkowe wymiary */
    let width = 0;
    let height = 0;

/* zakładamy, że szerokość i wysokość
   reprezentowane są przez taką samą
   ilość bajtów, stosujemy więc
   jedną pętlę.
   W środku, konwersji dokonujemy zgodnie
   z tabelką.
*/
    for (let i = 0; i < fileTypes[type]["dimensions"]["width"].length; i++) {
      if (fileTypes[type]["dimensions"]["type"] === "le") {
        width =
          width |
          (buffer[fileTypes[type]["dimensions"]["width"][i]] << (8 * i));

        height =
          height |
          (buffer[fileTypes[type]["dimensions"]["height"][i]] << (8 * i));
      } else {
        width =
          (width << 8) | buffer[fileTypes[type]["dimensions"]["width"][i]];

        height =
          (height << 8) | buffer[fileTypes[type]["dimensions"]["height"][i]];
      }
    }
    alert(type + " " + size + " " + width + " " + height);
  }
});

I nasza ostateczna wróżba.

Uwaga! Ammmmm, ammmmmm. Przewiduję, że niedługo ukaże się kolejny wpis!

 

Przydatne linki:
Dokumentacja formatu PNG
Dokumentacja formatu BMP
Dokumentacja formatu GIF
Obiekt File