PNG re-work: saving imager tags as PNG chunks
authorTony Cook <tony@develop-help.com>
Fri, 20 Apr 2012 10:15:25 +0000 (20:15 +1000)
committerTony Cook <tony@develop-help.com>
Sun, 29 Apr 2012 04:28:14 +0000 (14:28 +1000)
PNG/impng.c
PNG/t/10png.t

index 6a3ba6b..1df25ca 100644 (file)
@@ -33,6 +33,15 @@ write_paletted(png_structp png_ptr, png_infop info_ptr, i_img *im, int bits);
 static int
 write_bilevel(png_structp png_ptr, png_infop info_ptr, i_img *im);
 
+static void 
+get_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr, int bit_depth, int color_type);
+
+static int
+set_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr);
+
+static const char *
+get_string2(i_img_tags *tags, const char *name, char *buf, size_t *size);
+
 unsigned
 i_png_lib_version(void) {
   return png_access_version_number();
@@ -89,8 +98,6 @@ i_writepng_wiol(i_img *im, io_glue *ig) {
   png_infop info_ptr = NULL;
   i_img_dim width,height,y;
   volatile int cspace,channels;
-  double xres, yres;
-  int aspect_only, have_res;
   unsigned char *data;
   unsigned char * volatile vdata = NULL;
   int bits;
@@ -213,31 +220,12 @@ i_writepng_wiol(i_img *im, io_glue *ig) {
    */
   png_set_user_limits(png_ptr, width, height);
 
-  mm_log((1, ">png_set_IHDR\n"));
   png_set_IHDR(png_ptr, info_ptr, width, height, bits, cspace,
               PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_BASE, PNG_FILTER_TYPE_BASE);
-  mm_log((1, "<png_set_IHDR\n"));
 
-  have_res = 1;
-  if (i_tags_get_float(&im->tags, "i_xres", 0, &xres)) {
-    if (i_tags_get_float(&im->tags, "i_yres", 0, &yres))
-      ; /* nothing to do */
-    else
-      yres = xres;
-  }
-  else {
-    if (i_tags_get_float(&im->tags, "i_yres", 0, &yres))
-      xres = yres;
-    else
-      have_res = 0;
-  }
-  if (have_res) {
-    aspect_only = 0;
-    i_tags_get_int(&im->tags, "i_aspect_only", 0, &aspect_only);
-    xres /= 0.0254;
-    yres /= 0.0254;
-    png_set_pHYs(png_ptr, info_ptr, xres + 0.5, yres + 0.5, 
-                 aspect_only ? PNG_RESOLUTION_UNKNOWN : PNG_RESOLUTION_METER);
+  if (!set_png_tags(im, png_ptr, info_ptr)) {
+    png_destroy_write_struct(&png_ptr, &info_ptr);
+    return 0;
   }
 
   if (is_bilevel) {
@@ -275,9 +263,6 @@ i_writepng_wiol(i_img *im, io_glue *ig) {
   return(1);
 }
 
-static void 
-get_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr, int bit_depth, int color_type);
-
 typedef struct {
   char *warnings;
 } i_png_read_state, *i_png_read_statep;
@@ -684,6 +669,20 @@ text_tags[] = {
 
 static const int text_tags_count = sizeof(text_tags) / sizeof(*text_tags);
 
+static const char * const
+chroma_tags[] = {
+  "png_chroma_white_x",
+  "png_chroma_white_y",
+  "png_chroma_red_x",
+  "png_chroma_red_y",
+  "png_chroma_green_x",
+  "png_chroma_green_y",
+  "png_chroma_blue_x",
+  "png_chroma_blue_y"
+};
+
+static const int chroma_tag_count = sizeof(chroma_tags) / sizeof(*chroma_tags);
+
 static void
 get_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr,
             int bit_depth, int color_type) {
@@ -738,26 +737,19 @@ get_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr,
        that these are ignored if the sRGB is present, so ignore them.
     */
     double gamma;
-    double white_x, white_y;
-    double red_x, red_y;
-    double green_x, green_y;
-    double blue_x, blue_y;
+    double chroma[8];
 
     if (png_get_gAMA(png_ptr, info_ptr, &gamma)) {
       i_tags_set_float2(&im->tags, "png_gamma", 0, gamma, 4);
     }
 
-    if (png_get_cHRM(png_ptr, info_ptr, &white_x, &white_y,
-                    &red_x, &red_y, &green_x, &green_y,
-                    &blue_x, &blue_y)) {
-      i_tags_set_float2(&im->tags, "png_chroma_white_x", 0, white_x, 4);
-      i_tags_set_float2(&im->tags, "png_chroma_white_y", 0, white_y, 4);
-      i_tags_set_float2(&im->tags, "png_chroma_red_x", 0, red_x, 4);
-      i_tags_set_float2(&im->tags, "png_chroma_red_y", 0, red_y, 4);
-      i_tags_set_float2(&im->tags, "png_chroma_green_x", 0, green_x, 4);
-      i_tags_set_float2(&im->tags, "png_chroma_green_y", 0, green_y, 4);
-      i_tags_set_float2(&im->tags, "png_chroma_blue_x", 0, blue_x, 4);
-      i_tags_set_float2(&im->tags, "png_chroma_blue_y", 0, blue_y, 4);
+    if (png_get_cHRM(png_ptr, info_ptr, chroma+0, chroma+1,
+                    chroma+2, chroma+3, chroma+4, chroma+5,
+                    chroma+6, chroma+7)) {
+      int i;
+
+      for (i = 0; i < chroma_tag_count; ++i)
+       i_tags_set_float2(&im->tags, chroma_tags[i], 0, chroma[i], 4);
     }
   }
 
@@ -767,13 +759,21 @@ get_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr,
 
     if (png_get_text(png_ptr, info_ptr, &text, &num_text)) {
       int i;
+      int custom_index = 0;
       for (i = 0; i < num_text; ++i) {
        int j;
        int found = 0;
+       int compressed = text[i].compression == PNG_ITXT_COMPRESSION_zTXt
+         || text[i].compression == PNG_TEXT_COMPRESSION_zTXt;
 
        for (j = 0; j < text_tags_count; ++j) {
          if (strcmp(text_tags[j].keyword, text[i].key) == 0) {
+           char tag_name[50];
            i_tags_set(&im->tags, text_tags[j].tagname, text[i].text, -1);
+           if (compressed) {
+             sprintf(tag_name, "%s_compressed", text_tags[j].tagname);
+             i_tags_setn(&im->tags, tag_name, 1);
+           }
            found = 1;
            break;
          }
@@ -781,15 +781,20 @@ get_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr,
 
        if (!found) {
          char tag_name[50];
-         sprintf(tag_name, "png_text%d_key", i);
+         sprintf(tag_name, "png_text%d_key", custom_index);
          i_tags_set(&im->tags, tag_name, text[i].key, -1);
-         sprintf(tag_name, "png_text%d_text", i);
+         sprintf(tag_name, "png_text%d_text", custom_index);
          i_tags_set(&im->tags, tag_name, text[i].text, -1);
-         sprintf(tag_name, "png_text%d_type", i);
+         sprintf(tag_name, "png_text%d_type", custom_index);
          i_tags_set(&im->tags, tag_name, 
                     (text[i].compression == PNG_TEXT_COMPRESSION_NONE
                      || text[i].compression == PNG_TEXT_COMPRESSION_zTXt) ?
                     "text" : "itxt", -1);
+         if (compressed) {
+           sprintf(tag_name, "png_text%d_compressed", custom_index);
+           i_tags_setn(&im->tags, tag_name, 1);
+         }
+         ++custom_index;
        }
       }
     }
@@ -866,6 +871,261 @@ get_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr,
   }
 }
 
+#define GET_STR_BUF_SIZE 40
+
+static int
+set_png_tags(i_img *im, png_structp png_ptr, png_infop info_ptr) {
+  double xres, yres;
+  int aspect_only, have_res = 1;
+
+  if (i_tags_get_float(&im->tags, "i_xres", 0, &xres)) {
+    if (i_tags_get_float(&im->tags, "i_yres", 0, &yres))
+      ; /* nothing to do */
+    else
+      yres = xres;
+  }
+  else {
+    if (i_tags_get_float(&im->tags, "i_yres", 0, &yres))
+      xres = yres;
+    else
+      have_res = 0;
+  }
+  if (have_res) {
+    aspect_only = 0;
+    i_tags_get_int(&im->tags, "i_aspect_only", 0, &aspect_only);
+    xres /= 0.0254;
+    yres /= 0.0254;
+    png_set_pHYs(png_ptr, info_ptr, xres + 0.5, yres + 0.5, 
+                 aspect_only ? PNG_RESOLUTION_UNKNOWN : PNG_RESOLUTION_METER);
+  }
+
+  {
+    int intent;
+    if (i_tags_get_int(&im->tags, "png_srgb_intent", 0, &intent)) {
+      if (intent < 0 || intent >= PNG_sRGB_INTENT_LAST) {
+       i_push_error(0, "tag png_srgb_intent out of range");
+       return 0;
+      }
+      png_set_sRGB(png_ptr, info_ptr, intent);
+    }
+    else {
+      double chroma[8], gamma;
+      int i;
+      int found_chroma_count = 0;
+
+      for (i = 0; i < chroma_tag_count; ++i) {
+       if (i_tags_get_float(&im->tags, chroma_tags[i], 0, chroma+i))
+         ++found_chroma_count;
+      }
+
+      if (found_chroma_count) {
+       if (found_chroma_count != chroma_tag_count) {
+         i_push_error(0, "all png_chroma_* tags must be supplied or none");
+         return 0;
+       }
+
+       png_set_cHRM(png_ptr, info_ptr, chroma[0], chroma[1], chroma[2],
+                    chroma[3], chroma[4], chroma[5], chroma[6], chroma[7]);
+      }
+
+      if (i_tags_get_float(&im->tags, "png_gamma", 0, &gamma)) {
+       png_set_gAMA(png_ptr, info_ptr, gamma);
+      }
+    }
+  }
+
+  {
+    /* png_set_text() is sparsely documented, it isn't indicated whether
+       multiple calls add to or replace the lists of texts, and
+       whether the text/keyword data is copied or not.
+
+       Examining the linpng code reveals that png_set_text() adds to
+       the list and that the text is copied.
+    */
+    int i;
+
+    /* do our standard tags */
+    for (i = 0; i < text_tags_count; ++i) {
+      char buf[GET_STR_BUF_SIZE];
+      size_t size;
+      const char *data;
+      
+      data = get_string2(&im->tags, text_tags[i].tagname, buf, &size);
+      if (data) {
+       png_text text;
+       int compression = size > 1000;
+       char compress_tag[40];
+
+       if (memchr(data, '\0',  size)) {
+         i_push_errorf(0, "tag %s may not contain NUL characters", text_tags[i].tagname);
+         return 0;
+       }
+      
+       sprintf(compress_tag, "%s_compressed", text_tags[i].tagname);
+       i_tags_get_int(&im->tags, compress_tag, 0, &compression);
+       
+       text.compression = compression ? PNG_TEXT_COMPRESSION_zTXt
+         : PNG_TEXT_COMPRESSION_NONE;
+       text.key = (char *)text_tags[i].keyword;
+       text.text_length = size;
+       text.text = (char *)data;
+#ifdef PNG_iTXt_SUPPORTED
+       text.itxt_length = 0;
+       text.lang = NULL;
+       text.lang_key = NULL;
+#endif
+
+       png_set_text(png_ptr, info_ptr, &text, 1);
+      }
+    }
+
+    /* for non-standard tags ensure keywords are limited to 1 to 79
+       characters */
+    i = 0;
+    while (1) {
+      char tag_name[50];
+      char key_buf[GET_STR_BUF_SIZE], value_buf[GET_STR_BUF_SIZE];
+      const char *key, *value;
+      size_t key_size, value_size;
+
+      sprintf(tag_name, "png_text%d_key", i);
+      key = get_string2(&im->tags, tag_name, key_buf, &key_size);
+      
+      if (key) {
+       size_t k;
+       if (key_size < 1 || key_size > 79) {
+         i_push_errorf(0, "tag %s must be between 1 and 79 characters in length", tag_name);
+         return 0;
+       }
+
+       if (key[0] == ' ' || key[key_size-1] == ' ') {
+         i_push_errorf(0, "tag %s may not contain leading or trailing spaces", tag_name);
+         return 0;
+       }
+
+       if (strstr(key, "  ")) {
+         i_push_errorf(0, "tag %s may not contain consecutive spaces", tag_name);
+         return 0;
+       }
+
+       for (k = 0; k < key_size; ++k) {
+         if (key[k] < 32 || key[k] > 126 && key[k] < 161) {
+           i_push_errorf(0, "tag %s may only contain Latin1 characters 32-126, 161-255", tag_name);
+           return 0;
+         }
+       }
+      }
+
+      sprintf(tag_name, "png_text%d_text", i);
+      value = get_string2(&im->tags, tag_name, value_buf, &value_size);
+
+      if (value) {
+       if (memchr(value, '\0', value_size)) {
+         i_push_errorf(0, "tag %s may not contain NUL characters", tag_name);
+         return 0;
+       }
+      }
+
+      if (key && value) {
+       png_text text;
+       int compression = value_size > 1000;
+
+       sprintf(tag_name, "png_text%d_compressed", i);
+       i_tags_get_int(&im->tags, tag_name, 0, &compression);
+
+       text.compression = compression ? PNG_TEXT_COMPRESSION_zTXt
+         : PNG_TEXT_COMPRESSION_NONE;
+       text.key = (char *)key;
+       text.text_length = value_size;
+       text.text = (char *)value;
+#ifdef PNG_iTXt_SUPPORTED
+       text.itxt_length = 0;
+       text.lang = NULL;
+       text.lang_key = NULL;
+#endif
+
+       png_set_text(png_ptr, info_ptr, &text, 1);
+      }
+      else if (key) {
+       i_push_errorf(0, "tag png_text%d_key found but not png_text%d_text", i, i);
+       return 0;
+      }
+      else if (value) {
+       i_push_errorf(0, "tag png_text%d_text found but not png_text%d_key", i, i);
+       return 0;
+      }
+      else {
+       break;
+      }
+      ++i;
+    }
+  }
+
+  {
+    char buf[GET_STR_BUF_SIZE];
+    size_t time_size;
+    const char *timestr = get_string2(&im->tags, "png_time", buf, &time_size);
+
+    if (timestr) {
+      int year, month, day, hour, minute, second;
+      png_time mod_time;
+
+      if (sscanf(timestr, "%d-%d-%dT%d:%d:%d", &year, &month, &day, &hour, &minute, &second) == 6) {
+       /* rough validation */
+       if (month < 1 || month > 12
+           || day < 1 || day > 31
+           || hour < 0 || hour > 23
+           || minute < 0 || minute > 59
+           || second < 0 || second > 60) {
+         i_push_error(0, "invalid date/time for png_time");
+         return 0;
+       }
+       mod_time.year = year;
+       mod_time.month = month;
+       mod_time.day = day;
+       mod_time.hour = hour;
+       mod_time.minute = minute;
+       mod_time.second = second;
+
+       png_set_tIME(png_ptr, info_ptr, &mod_time);
+      }
+      else {
+       i_push_error(0, "png_time must be formatted 'y-m-dTh:m:s'");
+       return 0;
+      }
+    }
+  }
+
+  {
+    /* no bKGD support yet, maybe later
+       it may be simpler to do it in the individual writers
+     */
+  }
+
+  return 1;
+}
+
+static const char *
+get_string2(i_img_tags *tags, const char *name, char *buf, size_t *size) {
+  int index;
+
+  if (i_tags_find(tags, name, 0, &index)) {
+    const i_img_tag *entry = tags->tags + index;
+    
+    if (entry->data) {
+      *size = entry->size;
+
+      return entry->data;
+    }
+    else {
+      *size = sprintf(buf, "%d", entry->idata);
+
+      return buf;
+    }
+  }
+  return NULL;
+}
+
 static int
 write_direct8(png_structp png_ptr, png_infop info_ptr, i_img *im) {
   unsigned char *data, *volatile vdata = NULL;
index e0a127e..8b332d3 100644 (file)
@@ -10,7 +10,7 @@ my $debug_writes = 1;
 
 init_log("testout/t102png.log",1);
 
-plan tests => 211;
+plan tests => 248;
 
 # this loads Imager::File::PNG too
 ok($Imager::formats{"png"}, "must have png format");
@@ -585,6 +585,189 @@ SKIP:
      "background color");
 }
 
+SKIP:
+{ # test tag writing
+  my $im = Imager->new(xsize => 1, ysize => 1);
+  ok($im->write(file => "testout/tags.png",
+               i_comment => "A Comment",
+               png_author => "An Author",
+               png_author_compressed => 1,
+               png_copyright => "A Copyright",
+               png_creation_time => "16 April 2012 22:56:30+1000",
+               png_description => "A Description",
+               png_disclaimer => "A Disclaimer",
+               png_software => "Some Software",
+               png_source => "A Source",
+               png_title => "A Title",
+               png_warning => "A Warning",
+               png_text0_key => "Custom Key",
+               png_text0_text => "Custom Value",
+               png_text0_compressed => 1,
+               png_text1_key => "Custom Key2",
+               png_text1_text => "Another Custom Value",
+               png_time => "2012-04-20T00:15:10",
+              ),
+     "write with many tags")
+    or diag("Cannot write with many tags: ", $im->errstr);
+
+  my $imr = Imager->new(file => "testout/tags.png");
+  ok($imr, "read it back in")
+    or skip("Couldn't read it back: ". Imager->errstr, 1);
+
+  is_deeply({ map @$_, $imr->tags },
+           {
+            i_format => "png",
+            i_comment => "A Comment",
+            png_author => "An Author",
+            png_author_compressed => 1,
+            png_copyright => "A Copyright",
+            png_creation_time => "16 April 2012 22:56:30+1000",
+            png_description => "A Description",
+            png_disclaimer => "A Disclaimer",
+            png_software => "Some Software",
+            png_source => "A Source",
+            png_title => "A Title",
+            png_warning => "A Warning",
+            png_text0_key => "Custom Key",
+            png_text0_text => "Custom Value",
+            png_text0_compressed => 1,
+            png_text0_type => "text",
+            png_text1_key => "Custom Key2",
+            png_text1_text => "Another Custom Value",
+            png_text1_type => "text",
+            png_time => "2012-04-20T00:15:10",
+            png_interlace => 0,
+            png_interlace_name => "none",
+            png_bits => 8,
+           }, "check tags are what we expected");
+}
+
+SKIP:
+{ # cHRM test
+  my $im = Imager->new(xsize => 1, ysize => 1);
+  ok($im->write(file => "testout/tagschrm.png", type => "png",
+               png_chroma_white_x => 0.3,
+               png_chroma_white_y => 0.32,
+               png_chroma_red_x => 0.7,
+               png_chroma_red_y => 0.28,
+               png_chroma_green_x => 0.075,
+               png_chroma_green_y => 0.8,
+               png_chroma_blue_x => 0.175,
+               png_chroma_blue_y => 0.05),
+     "write cHRM chunk");
+  my $imr = Imager->new(file => "testout/tagschrm.png", ftype => "png");
+  ok($imr, "read tagschrm.png")
+    or diag("reading tagschrm.png: ".Imager->errstr);
+  $imr
+    or skip("read of tagschrm.png failed", 1);
+  is_deeply({ map @$_, $imr->tags },
+           {
+            i_format => "png",
+            png_interlace => 0,
+            png_interlace_name => "none",
+            png_bits => 8,
+            png_chroma_white_x => 0.3,
+            png_chroma_white_y => 0.32,
+            png_chroma_red_x => 0.7,
+            png_chroma_red_y => 0.28,
+            png_chroma_green_x => 0.075,
+            png_chroma_green_y => 0.8,
+            png_chroma_blue_x => 0.175,
+            png_chroma_blue_y => 0.05,
+           }, "check chroma tags written");
+}
+
+{ # gAMA
+  my $im = Imager->new(xsize => 1, ysize => 1);
+  ok($im->write(file => "testout/tagsgama.png", type => "png",
+              png_gamma => 2.22),
+     "write with png_gammma tag");
+  my $imr = Imager->new(file => "testout/tagsgama.png", ftype => "png");
+  ok($imr, "read tagsgama.png")
+    or diag("reading tagsgama.png: ".Imager->errstr);
+  $imr
+    or skip("read of tagsgama.png failed", 1);
+  is_deeply({ map @$_, $imr->tags },
+           {
+            i_format => "png",
+            png_interlace => 0,
+            png_interlace_name => "none",
+            png_bits => 8,
+            png_gamma => "2.22",
+           }, "check gamma tag written");
+}
+
+{ # various bad tag failures
+  my @tests =
+    (
+     [
+      [ png_chroma_white_x => 0.5 ],
+      "all png_chroma_* tags must be supplied or none"
+     ],
+     [
+      [ png_srgb_intent => 4 ],
+      "tag png_srgb_intent out of range"
+     ],
+     [
+      [ i_comment => "test\0with nul" ],
+      "tag i_comment may not contain NUL characters"
+     ],
+     [
+      [ png_text0_key => "" ],
+      "tag png_text0_key must be between 1 and 79 characters in length"
+     ],
+     [
+      [ png_text0_key => ("x" x 80) ],
+      "tag png_text0_key must be between 1 and 79 characters in length"
+     ],
+     [
+      [ png_text0_key => " x" ],
+      "tag png_text0_key may not contain leading or trailing spaces"
+     ],
+     [
+      [ png_text0_key => "x " ],
+      "tag png_text0_key may not contain leading or trailing spaces"
+     ],
+     [
+      [ png_text0_key => "x  y" ],
+      "tag png_text0_key may not contain consecutive spaces"
+     ],
+     [
+      [ png_text0_key => "\x7F" ],
+      "tag png_text0_key may only contain Latin1 characters 32-126, 161-255"
+     ],
+     [
+      [ png_text0_key => "x", png_text0_text => "a\0b" ],
+      "tag png_text0_text may not contain NUL characters"
+     ],
+     [
+      [ png_text0_key => "test" ],
+      "tag png_text0_key found but not png_text0_text"
+     ],
+     [
+      [ png_text0_text => "test" ],
+      "tag png_text0_text found but not png_text0_key"
+     ],
+     [
+      [ png_time => "bad format" ],
+      "png_time must be formatted 'y-m-dTh:m:s'"
+     ],
+     [
+      [ png_time => "2012-13-01T00:00:00" ],
+      "invalid date/time for png_time"
+     ],
+    ); 
+  my $im = Imager->new(xsize => 1, ysize => 1);
+  for my $test (@tests) {
+    my ($tags, $error) = @$test;
+    my $im2 = $im->copy;
+    my $data;
+    ok(!$im2->write(data => \$data, type => "png", @$tags),
+       "expect $error");
+    is($im2->errstr, $error, "check error message");
+  }
+}
+
 sub limited_write {
   my ($limit) = @_;