diff --git a/CHANGES.txt b/CHANGES.txt index 1febdfb..24d9616 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,13 +1,14 @@ -Feb 09, 2014 - Removed support for Python 2.6. Added write support for JP2 - UUID, DataEntryURL, Palette and Component Mapping boxes, JPX - Association, NumberList and DataReference boxes. Added read - support for JPX free, number list, data reference, fragment - table, and fragment list boxes. Improved JPX Reader Requirements box - support. Added get_printoptions, set_printoptions functions. - Palette box now a 2D numpy array instead of a list of 1D arrays. - JP2 super box constructors now take optional box list argument. - Fixed bug where JPX files with more than one codestream but - advertising jp2 compatibility were not being read. +Feb 09, 2014 - Changed constructor for ChannelDefinition box. Removed support + for Python 2.6. Added write support for JP2 UUID, DataEntryURL, + Palette and Component Mapping boxes, JPX Association, NumberList + and DataReference boxes. Added read support for JPX free, + number list, data reference, fragment table, and fragment list + boxes. Improved JPX Reader Requirements box support. Added + get_printoptions, set_printoptions functions. Palette box now + a 2D numpy array instead of a list of 1D arrays. JP2 super box + constructors now take optional box list argument. Fixed bug + where JPX files with more than one codestream but advertising + jp2 compatibility were not being read. Jan 28, 2014 - v0.5.10 Fixed bad warning when reader requirements box mask length is unsupported. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index f7e5921..ad4e40b 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -11,6 +11,7 @@ ChangeLog * added write support for JP2 UUID, dataEntryURL, palette, and component mapping boxes * added read/write support for JPX free, number list, and data reference boxes * Added read support for JPX fragment list and fragment table boxes + * incompatible change to channel definition box constructor, channel_type and association are no longer keyword arguments * incompatible change to palette box constructor, it now takes a 2D numpy array instead of a list of 1D arrays 0.5.0 (September 16, 2013) diff --git a/docs/source/how_do_i.rst b/docs/source/how_do_i.rst index a1fe089..fa8f098 100644 --- a/docs/source/how_do_i.rst +++ b/docs/source/how_do_i.rst @@ -359,7 +359,7 @@ channel, but we aren't doing that). :: >>> from glymur.core import RED, GREEN, BLUE, WHOLE_IMAGE >>> asoc = [RED, GREEN, BLUE, WHOLE_IMAGE] - >>> cdef = glymur.jp2box.ChannelDefinitionBox(channel_type=ctype, association=asoc) + >>> cdef = glymur.jp2box.ChannelDefinitionBox(ctype, asoc) >>> print(cdef) Channel Definition Box (cdef) @ (0, 0) Channel 0 (color) ==> (1) diff --git a/glymur/_uuid_io.py b/glymur/_uuid_io.py index d4971a1..ed610e5 100644 --- a/glymur/_uuid_io.py +++ b/glymur/_uuid_io.py @@ -146,7 +146,7 @@ class _Ifd(object): tag_name = tagnum2name[tag] except KeyError: # Ok, we don't recognize this tag. Just use the numeric id. - msg = 'Unrecognized Exif tag "{0}".'.format(tag) + msg = 'Unrecognized Exif tag: {0}'.format(tag) warnings.warn(msg, UserWarning) tag_name = tag self.processed_ifd[tag_name] = value diff --git a/glymur/jp2box.py b/glymur/jp2box.py index a599f84..3cf637d 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -29,6 +29,7 @@ import numpy as np from .codestream import Codestream from .core import _COLORSPACE_MAP_DISPLAY from .core import _COLOR_TYPE_MAP_DISPLAY +from .core import SRGB, GREYSCALE, YCC from .core import ENUMERATED_COLORSPACE, RESTRICTED_ICC_PROFILE from .core import ANY_ICC_PROFILE, VENDOR_COLOR_METHOD from .core import _pretty_print_xml @@ -63,6 +64,8 @@ class Jp2kBox(object): offset of the box from the start of the file. longname : str more verbose description of the box. + box : list + List of JPEG 2000 boxes. """ def __init__(self, box_id='', offset=0, length=0, longname=''): @@ -70,6 +73,7 @@ class Jp2kBox(object): self.length = length self.offset = offset self.longname = longname + self.box = [] def __repr__(self): msg = "glymur.jp2box.Jp2kBox(box_id='{0}', offset={1}, length={2}, " @@ -88,6 +92,18 @@ class Jp2kBox(object): msg = "Not supported for {0} box.".format(self.longname) raise NotImplementedError(msg) + def _str_superbox(self): + """__str__ method for all superboxes.""" + msg = Jp2kBox.__str__(self) + for box in self.box: + boxstr = str(box) + + # Add indentation. + strs = [('\n ' + x) for x in boxstr.split('\n')] + msg += ''.join(strs) + return msg + + def _write_superbox(self, fptr): """Write a superbox. @@ -108,6 +124,54 @@ class Jp2kBox(object): fptr.write(struct.pack('>I', end_pos - orig_pos)) fptr.seek(end_pos) + def _parse_this_box(self, fptr, box_id, start, num_bytes): + """Parse the current box. + + Parameters + ---------- + fptr : file + Open file object. + box_id : str + 4-letter identifier for the current box. + start, num_bytes: int + Byte offset and length of the current box. + + Returns + ------- + box : Jp2kBox + object corresponding to the current box + """ + try: + box = _BOX_WITH_ID[box_id].parse(fptr, start, num_bytes) + except KeyError: + msg = 'Unrecognized box ({0}) encountered.'.format(box_id) + warnings.warn(msg) + box = UnknownBox(box_id, offset=start, length=num_bytes, + longname='Unknown') + + cpos = fptr.tell() + if not ((cpos == start + 8) or (cpos == start + 16)): + # If the file pointer has advanced, then the KeyError + # ocurred during the parsing of the box. + pass + else: + # Could it be a superbox with recognizable child boxes? + # Peek ahead to see. + pos = fptr.tell() + read_buffer = fptr.read(8) + _, sub_id = struct.unpack('>I4s', read_buffer) + sub_id = sub_id.decode('utf-8') + + # Regardless of whether or not we recognize the box, rewind back + # to properly advance to the next box. + fptr.seek(pos) + + # Now process any child boxes if we actually did recognize it. + if sub_id in _BOX_WITH_ID.keys(): + box.box = box.parse_superbox(fptr) + + return box + def parse_superbox(self, fptr): """Parse a superbox (box consisting of nothing but other boxes. @@ -147,16 +211,10 @@ class Jp2kBox(object): num_bytes, = struct.unpack('>Q', read_buffer) else: + # The box_length value really is the length of the box! num_bytes = box_length - # Call the proper parser for the given box with ID "T". - try: - box = _BOX_WITH_ID[box_id].parse(fptr, start, num_bytes) - except KeyError: - msg = 'Unrecognized box ({0}) encountered.'.format(box_id) - warnings.warn(msg) - box = Jp2kBox(box_id, offset=start, length=num_bytes, - longname='Unknown box') + box = self._parse_this_box(fptr, box_id, start, num_bytes) superbox.append(box) @@ -213,13 +271,6 @@ class ColourSpecificationBox(Jp2kBox): approximation=0, colorspace=None, icc_profile=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='colr', longname='Colour Specification') - - if colorspace is not None and icc_profile is not None: - raise IOError("colorspace and icc_profile cannot both be set.") - if method not in (1, 2, 3, 4): - raise IOError("Invalid method.") - if approximation not in (0, 1, 2, 3, 4): - raise IOError("Invalid approximation.") self.method = method self.precedence = precedence self.approximation = approximation @@ -227,6 +278,33 @@ class ColourSpecificationBox(Jp2kBox): self.icc_profile = icc_profile self.length = length self.offset = offset + self._validate() + + def _validate(self): + """Verify that the box obeys the specifications.""" + if self.colorspace is not None and self.icc_profile is not None: + raise IOError("colorspace and icc_profile cannot both be set.") + if self.method not in (1, 2, 3, 4): + raise IOError("Invalid method.") + if self.approximation not in (0, 1, 2, 3, 4): + raise IOError("Invalid approximation.") + + def _write_validate(self): + """In addition to constructor validation steps, run validation steps + for writing.""" + if self.colorspace is None: + msg = "Writing Colour Specification boxes without enumerated " + msg += "colorspaces is not supported at this time." + raise IOError(msg) + + if self.icc_profile is None: + if self.colorspace not in [SRGB, GREYSCALE, YCC]: + msg = "Colorspace should correspond to one of SRGB, GREYSCALE, " + msg += "or YCC." + raise IOError(msg) + + self._validate() + def __repr__(self): msg = "glymur.jp2box.ColourSpecificationBox(" @@ -270,10 +348,7 @@ class ColourSpecificationBox(Jp2kBox): def write(self, fptr): """Write an Colour Specification box to file. """ - if self.colorspace is None: - msg = "Writing Colour Specification boxes without enumerated " - msg += "colorspaces is not supported at this time." - raise NotImplementedError(msg) + self._write_validate() length = 15 if self.icc_profile is None else 11 + len(self.icc_profile) fptr.write(struct.pack('>I', length)) fptr.write('colr'.encode()) @@ -469,23 +544,29 @@ class ChannelDefinitionBox(Jp2kBox): association : list index of the associated color """ - def __init__(self, index=None, channel_type=None, association=None, - **kwargs): + def __init__(self, channel_type, association, index=None, **kwargs): Jp2kBox.__init__(self, box_id='cdef', longname='Channel Definition') - # channel type and association must be specified. - if channel_type is None or association is None: - raise IOError("channel_type and association must be specified.") - if index is None: - index = list(range(len(channel_type))) + self.index = tuple(range(len(channel_type))) + else: + self.index = tuple(index) - if len(index) != len(channel_type) or len(index) != len(association): + self.channel_type = tuple(channel_type) + self.association = tuple(association) + self.__dict__.update(**kwargs) + self._validate() + + def _validate(self): + """Verify that the box obeys the specifications.""" + # channel type and association must be specified. + if not ((len(self.index) == len(self.channel_type)) and + (len(self.channel_type) == len(self.association))): msg = "Length of channel definition box inputs must be the same." raise IOError(msg) # channel types must be one of 0, 1, 2, 65535 - if any(x not in [0, 1, 2, 65535] for x in channel_type): + if any(x not in [0, 1, 2, 65535] for x in self.channel_type): msg = "Channel types must be in the set of\n\n" msg += " 0 - colour image data for associated color\n" msg += " 1 - opacity\n" @@ -493,10 +574,6 @@ class ChannelDefinitionBox(Jp2kBox): msg += " 65535 - unspecified" raise IOError(msg) - self.index = tuple(index) - self.channel_type = tuple(channel_type) - self.association = tuple(association) - self.__dict__.update(**kwargs) def __str__(self): msg = Jp2kBox.__str__(self) @@ -522,6 +599,7 @@ class ChannelDefinitionBox(Jp2kBox): def write(self, fptr): """Write a channel definition box to file. """ + self._validate() num_components = len(self.association) fptr.write(struct.pack('>I', 8 + 2 + num_components * 6)) fptr.write('cdef'.encode('utf-8')) @@ -559,9 +637,10 @@ class ChannelDefinitionBox(Jp2kBox): channel_type = data[1:num_components * 6:3] association = data[2:num_components * 6:3] - box = ChannelDefinitionBox(index=index, channel_type=channel_type, - association=association, length=length, - offset=offset) + box = ChannelDefinitionBox(index=tuple(index), + channel_type=tuple(channel_type), + association=tuple(association), + length=length, offset=offset) return box @@ -592,16 +671,7 @@ class CodestreamHeaderBox(Jp2kBox): return msg def __str__(self): - msg = Jp2kBox.__str__(self) - if _printoptions['short'] == True: - return msg - - for box in self.box: - boxstr = str(box) - - # Add indentation. - strs = [('\n ' + x) for x in boxstr.split('\n')] - msg += ''.join(strs) + msg = self._str_superbox() return msg @staticmethod @@ -659,16 +729,7 @@ class CompositingLayerHeaderBox(Jp2kBox): return msg def __str__(self): - msg = Jp2kBox.__str__(self) - if _printoptions['short'] == True: - return msg - - for box in self.box: - boxstr = str(box) - - # Add indentation. - strs = [('\n ' + x) for x in boxstr.split('\n')] - msg += ''.join(strs) + msg = self._str_superbox() return msg @staticmethod @@ -738,7 +799,6 @@ class ComponentMappingBox(Jp2kBox): if _printoptions['short'] == True: return msg - for k in range(len(self.component_index)): if self.mapping_type[k] == 1: msg += '\n Component {0} ==> palette column {1}' @@ -831,10 +891,10 @@ class ContiguousCodestreamBox(Jp2kBox): msg += '\n Main header:' for segment in self.main_header.segment: segstr = str(segment) - # Add indentation. strs = [('\n ' + x) for x in segstr.split('\n')] msg += ''.join(strs) + return msg @staticmethod @@ -876,15 +936,36 @@ class DataReferenceBox(Jp2kBox): DR : list Data Entry URL boxes. """ - def __init__(self, data_entry_url_boxes, length=0, offset=-1): + def __init__(self, data_entry_url_boxes=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='dtbl', longname='Data Reference') - self.DR = data_entry_url_boxes + if data_entry_url_boxes is None: + self.DR = [] + else: + self.DR = data_entry_url_boxes self.length = length self.offset = offset + self._validate() + + def _validate(self): + """Verify that the box obeys the specifications.""" + for box in self.DR: + if box.box_id != 'url ': + msg = 'All child boxes of a data reference box must be data ' + msg += 'entry URL boxes.' + raise IOError(msg) + + def _write_validate(self): + """Verify that the box obeys the specifications for writing. + """ + if len(self.DR) == 0: + msg = "A data reference box cannot be empty when written to a file." + raise IOError(msg) + self._validate() def write(self, fptr): """Write a Data Reference box to file. """ + self._write_validate() # Very similar to the say a superbox is written. orig_pos = fptr.tell() @@ -980,7 +1061,6 @@ class FileTypeBox(Jp2kBox): self.brand = brand self.minor_version = minor_version if compatibility_list is None: - # see W0102, pylint self.compatibility_list = ['jp2 '] else: self.compatibility_list = compatibility_list @@ -1007,9 +1087,22 @@ class FileTypeBox(Jp2kBox): return msg + def _validate(self): + """Validate the box before writing to file.""" + if self.brand not in ['jp2 ', 'jpx ']: + msg = "The file type brand must be either 'jp2 ' or 'jpx '." + raise IOError(msg) + valid_cls = ['jp2 ', 'jpx ', 'jpxb'] + for item in self.compatibility_list: + if item not in valid_cls: + msg = "The file type compatibility list item '{0}' is not " + msg += "valid: valid entries are {1}" + raise IOError(msg.format(item, valid_cls)) + def write(self, fptr): """Write a File Type box to file. """ + self._validate() length = 16 + 4*len(self.compatibility_list) fptr.write(struct.pack('>I', length)) fptr.write('ftyp'.encode()) @@ -1084,6 +1177,18 @@ class FragmentListBox(Jp2kBox): self.length = length self.offset = offset + def _validate(self): + """Validate internal correctness.""" + if (((len(self.fragment_offset) != len(self.fragment_length)) or + (len(self.fragment_length) != len(self.data_reference)))): + msg = "The lengths of the fragment offsets, fragment lengths, and " + msg += "data reference items must be the same." + raise IOError(msg) + if any([x <= 0 for x in self.fragment_offset]): + raise IOError("Fragment offsets must all be positive.") + if any([x <= 0 for x in self.fragment_length]): + raise IOError("Fragment lengths must all be positive.") + def __repr__(self): msg = "glymur.jp2box.FragmentListBox()" return msg @@ -1103,6 +1208,22 @@ class FragmentListBox(Jp2kBox): return msg + def write(self, fptr): + """Write a fragment list box to file. + """ + self._validate() + num_items = len(self.fragment_offset) + length = 8 + 2 + num_items * 14 + fptr.write(struct.pack('>I', length)) + fptr.write(self.box_id.encode()) + fptr.write(struct.pack('>H', num_items)) + for j in range(num_items): + write_buffer = struct.pack('>QIH', + self.fragment_offset[j], + self.fragment_length[j], + self.data_reference[j]) + fptr.write(write_buffer) + @staticmethod def parse(fptr, offset, length): """Parse JPX free box. @@ -1146,26 +1267,18 @@ class FragmentTableBox(Jp2kBox): longname : str more verbose description of the box. """ - def __init__(self, length=0, offset=-1): + def __init__(self, box=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='ftbl', longname='Fragment Table') self.length = length self.offset = offset + self.box = box if box is not None else [] def __repr__(self): msg = "glymur.jp2box.FragmentTableBox()" return msg def __str__(self): - msg = Jp2kBox.__str__(self) - if _printoptions['short'] == True: - return msg - - for box in self.box: - boxstr = str(box) - - # Add indentation. - strs = [('\n ' + x) for x in boxstr.split('\n')] - msg += ''.join(strs) + msg = self._str_superbox() return msg @staticmethod @@ -1183,7 +1296,7 @@ class FragmentTableBox(Jp2kBox): Returns ------- - FreeBox instance + FragmentTableBox instance """ box = FragmentTableBox(length=length, offset=offset) @@ -1193,6 +1306,20 @@ class FragmentTableBox(Jp2kBox): return box + def _validate(self): + """Self-validate the box before writing.""" + box_ids = [box.box_id for box in self.box] + if len(box_ids) != 1 or box_ids[0] != 'flst': + msg = "Fragment table boxes must have a single fragment list " + msg += "box as a child box." + raise IOError(msg) + + def write(self, fptr): + """Write a fragment table box to file. + """ + self._validate() + self._write_superbox(fptr) + class FreeBox(Jp2kBox): @@ -1415,16 +1542,7 @@ class AssociationBox(Jp2kBox): return msg def __str__(self): - msg = Jp2kBox.__str__(self) - if _printoptions['short'] == True: - return msg - - for box in self.box: - boxstr = str(box) - - # Add indentation. - strs = [('\n ' + x) for x in boxstr.split('\n')] - msg += ''.join(strs) + msg = self._str_superbox() return msg @staticmethod @@ -1485,13 +1603,7 @@ class JP2HeaderBox(Jp2kBox): return msg def __str__(self): - msg = Jp2kBox.__str__(self) - for box in self.box: - boxstr = str(box) - - # Add indentation. - strs = [('\n ' + x) for x in boxstr.split('\n')] - msg += ''.join(strs) + msg = self._str_superbox() return msg def write(self, fptr): @@ -1616,6 +1728,15 @@ class PaletteBox(Jp2kBox): self.signed = signed self.length = length self.offset = offset + self._validate() + + def _validate(self): + """Verify that the box obeys the specifications.""" + if ((len(self.bits_per_component) != len(self.signed)) or + (len(self.signed) != self.palette.shape[1])): + msg = "The length of the 'bits_per_component' and the 'signed' " + msg += "members must equal the number of columns of the palette." + raise IOError(msg) def __repr__(self): msg = "glymur.jp2box.PaletteBox({0}, bits_per_component={1}, " @@ -1635,6 +1756,7 @@ class PaletteBox(Jp2kBox): def write(self, fptr): """Write a Palette box to file. """ + self._validate() bytes_per_row = sum(self.bits_per_component) / 8 bytes_per_palette = bytes_per_row * self.palette.shape[0] box_length = 8 + 3 + self.palette.shape[1] + bytes_per_palette @@ -1714,8 +1836,7 @@ class PaletteBox(Jp2kBox): palette[j] = struct.unpack_from(fmt, read_buffer, offset=j * row_nbytes) - box = PaletteBox(palette, bps, signed, length=length, offset=offset) - return box + return PaletteBox(palette, bps, signed, length=length, offset=offset) # Map rreq codes to display text. @@ -2086,13 +2207,7 @@ class ResolutionBox(Jp2kBox): return msg def __str__(self): - msg = Jp2kBox.__str__(self) - for box in self.box: - boxstr = str(box) - - # Add indentation. - strs = [('\n ' + x) for x in boxstr.split('\n')] - msg += ''.join(strs) + msg = self._str_superbox() return msg @staticmethod @@ -2612,14 +2727,7 @@ class UUIDInfoBox(Jp2kBox): return msg def __str__(self): - msg = Jp2kBox.__str__(self) - for box in self.box: - box_str = str(box) - - # Add indentation. - lst = [('\n ' + x) for x in box_str.split('\n')] - msg += ''.join(lst) - + msg = self._str_superbox() return msg @staticmethod @@ -2680,13 +2788,18 @@ class DataEntryURLBox(Jp2kBox): def write(self, fptr): """Write a data entry url box to file. """ - length = 8 + 1 + 3 + len(self.url.encode()) + # Make sure it is written out as null-terminated. + url = self.url + if self.url[-1] != chr(0): + url = url + chr(0) + + length = 8 + 1 + 3 + len(url.encode()) write_buffer = struct.pack('>I4sBBBB', length, self.box_id.encode(), self.version, self.flag[0], self.flag[1], self.flag[2]) fptr.write(write_buffer) - fptr.write(self.url.encode()) + fptr.write(url.encode()) def __repr__(self): @@ -2739,6 +2852,37 @@ class DataEntryURLBox(Jp2kBox): return box +class UnknownBox(Jp2kBox): + """Container for unrecognized boxes. + + Attributes + ---------- + box_id : str + 4-character identifier for the box. + length : int + length of the box in bytes. + offset : int + offset of the box from the start of the file. + longname : str + more verbose description of the box. + """ + def __init__(self, box_id, length=0, offset=-1, longname=''): + Jp2kBox.__init__(self, box_id=box_id, longname=longname) + self.length = length + self.offset = offset + + def __repr__(self): + msg = "glymur.jp2box.UnknownBox({0})".format(self.box_id) + return msg + + def __str__(self): + if len(self.box) > 0: + msg = self._str_superbox() + else: + msg = Jp2kBox.__str__(self) + return msg + + class UUIDBox(Jp2kBox): """Container for UUID box information. diff --git a/glymur/jp2k.py b/glymur/jp2k.py index 96b6e68..6db0198 100644 --- a/glymur/jp2k.py +++ b/glymur/jp2k.py @@ -1110,6 +1110,10 @@ def _validate_nonzero_image_size(nrows, ncols, component_index): raise IOError(msg) +JP2_IDS = ['colr', 'cdef', 'cmap', 'jp2c', 'ftyp', 'ihdr', 'jp2h', 'jP ', + 'pclr', 'res ', 'resc', 'resd', 'xml ', 'ulst', 'uinf', 'url ', + 'uuid'] + def _validate_jp2_box_sequence(boxes): """Run through series of tests for JP2 box legality. @@ -1118,14 +1122,24 @@ def _validate_jp2_box_sequence(boxes): _validate_signature_compatibility(boxes) _validate_jp2h(boxes) _validate_jp2c(boxes) - _validate_association(boxes) + if boxes[1].brand == 'jpx ': + _validate_jpx_box_sequence(boxes) + else: + count = _collect_box_count(boxes) + for id in count.keys(): + if id not in JP2_IDS: + msg = "The presence of a '{0}' box requires that the file type " + msg += "brand be set to 'jpx '." + raise IOError(msg.format(id)) + +def _validate_jpx_box_sequence(boxes): + """Run through series of tests for JPX box legality.""" _validate_label(boxes) _validate_jpx_brand(boxes, boxes[1].brand) _validate_jpx_compatibility(boxes, boxes[1].compatibility_list) _validate_singletons(boxes) _validate_top_level(boxes) - def _validate_signature_compatibility(boxes): """Validate the file signature and compatibility status.""" # Check for a bad sequence of boxes. @@ -1268,6 +1282,12 @@ def _validate_top_level(boxes): if 'dtbl' in multiples: raise IOError('There can only be one dtbl box in a file.') + # If there is one data reference box, then there must also be one ftbl. + if 'dtbl' in count and 'ftbl' not in count: + msg = 'The presence of a data reference box requires the presence of ' + msg += 'a fragment table box as well.' + raise IOError(msg) + def _validate_singletons(boxes): """Several boxes can only occur once.""" count = _collect_box_count(boxes) @@ -1321,23 +1341,6 @@ def _validate_label(boxes): # Same set of checks on any child boxes. _validate_label(box.box) -def _validate_association(boxes): - """ - Association boxes can only contain number list boxes and xml boxes, as far - as we know. - """ - for box in boxes: - if box.box_id == 'asoc': - if box.box[0].box_id != 'nlst' or box.box[1].box_id != 'xml ': - msg = "An Association box can only contain a NumberList box " - msg += "followed by an XML box." - raise RuntimeError(msg) - if hasattr(box, 'box') != 0: - # Same set of checks on any child boxes. - _validate_association(box.box) - - - def extract_image_cube(image): """Extract 3D image from openjpeg data structure. """ diff --git a/glymur/test/fixtures.py b/glymur/test/fixtures.py index 6e0a76c..a7906c5 100644 --- a/glymur/test/fixtures.py +++ b/glymur/test/fixtures.py @@ -690,3 +690,11 @@ file7_rreq = r"""Reader Requirements Box (rreq) @ (44, 24) Feature 060: 0x60 e-sRGB enumerated colorspace Feature 043: 0x40 Deprecated - compositing layer uses restricted ICC profile Vendor Features:""" + +file1_xml = r"""XML Box (xml ) @ (36, 439) + + + 2001-11-01T13:45:00.000-06:00 + Professional 120 Image + + """ diff --git a/glymur/test/test_jp2box.py b/glymur/test/test_jp2box.py index feffec9..5861971 100644 --- a/glymur/test/test_jp2box.py +++ b/glymur/test/test_jp2box.py @@ -77,6 +77,28 @@ class TestDataEntryURL(unittest.TestCase): self.assertEqual(jp22.box[4].flag, (0, 0, 0)) self.assertEqual(jp22.box[4].url, url) + def test_null_termination(self): + """I.9.3.2 specifies that the location field must be null terminated.""" + jp2 = Jp2k(self.jp2file) + + url = 'http://glymur.readthedocs.org' + deurl = glymur.jp2box.DataEntryURLBox(0, (0, 0, 0), url) + boxes = [box for box in jp2.box if box.box_id != 'uuid'] + boxes.append(deurl) + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + jp22 = jp2.wrap(tfile.name, boxes=boxes) + + self.assertEqual(jp22.box[-1].length, 42) + + # Go to the last box. Seek past the L, T, version, and flag fields. + with open(tfile.name, 'rb') as fptr: + fptr.seek(jp22.box[-1].offset + 4 + 4 + 1 + 3) + + nbytes = jp22.box[-1].offset + jp22.box[-1].length - fptr.tell() + read_buffer = fptr.read(nbytes) + read_url = read_buffer.decode('utf-8') + self.assertEqual(url + chr(0), read_url) + @unittest.skipIf(glymur.version.openjpeg_version_tuple[0] < 2 or OPENJP2_IS_V2_OFFICIAL, @@ -139,7 +161,7 @@ class TestChannelDefinition(unittest.TestCase): def test_cdef_no_inputs(self): """channel_type and association are required inputs.""" - with self.assertRaises(IOError): + with self.assertRaises(TypeError): glymur.jp2box.ChannelDefinitionBox() def test_rgb_with_index(self): @@ -340,6 +362,29 @@ class TestChannelDefinition(unittest.TestCase): association=association) +class TestFileTypeBox(unittest.TestCase): + """Test suite for ftyp box issues.""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_brand_unknown(self): + """A ftyp box brand must be 'jp2 ' or 'jpx '.""" + ftyp = glymur.jp2box.FileTypeBox(brand='jp3') + with self.assertRaises(IOError): + with tempfile.TemporaryFile() as tfile: + ftyp.write(tfile) + + def test_cl_entry_unknown(self): + """A ftyp box cl list can only contain 'jp2 ', 'jpx ', or 'jpxb'.""" + ftyp = glymur.jp2box.FileTypeBox(compatibility_list=['jp3']) + with self.assertRaises(IOError): + with tempfile.TemporaryFile() as tfile: + ftyp.write(tfile) + class TestColourSpecificationBox(unittest.TestCase): """Test suite for colr box instantiation.""" @@ -371,7 +416,7 @@ class TestColourSpecificationBox(unittest.TestCase): boxes = [self.jp2b, self.ftyp, self.jp2h, self.jp2c] boxes[2].box = [self.ihdr, ColourSpecificationBox(colorspace=None)] with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: - with self.assertRaises(NotImplementedError): + with self.assertRaises(IOError): j2k.wrap(tfile.name, boxes=boxes) @unittest.skipIf(os.name == "nt", "Temporary file issue on window.") @@ -417,6 +462,46 @@ class TestColourSpecificationBox(unittest.TestCase): glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, approximation=approx) + def test_colr_with_bad_color(self): + """colr must have a valid color, strange as though that may sound.""" + colorspace = -1 + approx = 0 + colr = glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, + approximation=approx) + with tempfile.TemporaryFile() as tfile: + with self.assertRaises(IOError): + colr.write(tfile) + + +@unittest.skipIf(os.name == "nt", + "Problems using NamedTemporaryFile on windows.") +class TestPaletteBox(unittest.TestCase): + """Test suite for pclr box instantiation.""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_mismatched_bitdepth_signed(self): + """bitdepth and signed arguments must have equal length""" + palette = np.array([[255, 0, 255], [0, 255, 0]], dtype=np.uint8) + bps = (8, 8, 8) + signed = (False, False) + with self.assertRaises(IOError): + pclr = glymur.jp2box.PaletteBox(palette, bits_per_component=bps, + signed=signed) + + def test_mismatched_signed_palette(self): + """bitdepth and signed arguments must have equal length""" + palette = np.array([[255, 0, 255], [0, 255, 0]], dtype=np.uint8) + bps = (8, 8, 8, 8) + signed = (False, False, False, False) + with self.assertRaises(IOError): + pclr = glymur.jp2box.PaletteBox(palette, bits_per_component=bps, + signed=signed) + class TestAppend(unittest.TestCase): """Tests for append method.""" diff --git a/glymur/test/test_jp2box_jpx.py b/glymur/test/test_jp2box_jpx.py index 8b5e075..7493dea 100644 --- a/glymur/test/test_jp2box_jpx.py +++ b/glymur/test/test_jp2box_jpx.py @@ -8,12 +8,12 @@ import struct import sys import tempfile import unittest -import warnings import xml.etree.cElementTree as ET import glymur from glymur import Jp2k - +from glymur.jp2box import DataEntryURLBox, FileTypeBox, JPEG2000SignatureBox +from glymur.jp2box import DataReferenceBox, FragmentListBox, FragmentTableBox @unittest.skipIf(os.name == "nt", "Temporary file issue on window.") class TestJPXWrap(unittest.TestCase): @@ -21,6 +21,7 @@ class TestJPXWrap(unittest.TestCase): def setUp(self): self.jp2file = glymur.data.nemo() + self.j2kfile = glymur.data.goodstuff() raw_xml = b""" @@ -40,6 +41,100 @@ class TestJPXWrap(unittest.TestCase): def tearDown(self): os.unlink(self.xmlfile) + def test_jpx_ftbl_no_codestream(self): + """Can have a jpx with no codestream.""" + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile1: + with open(self.jp2file, 'rb') as fptr: + tfile1.write(fptr.read()) + tfile1.flush() + jp2_1 = Jp2k(tfile1.name) + + jp2c = [box for box in jp2_1.box if box.box_id == 'jp2c'][0] + + # coff and clen will be the offset and length input arguments + # to the fragment list box. dr_idx is the data reference index. + coff = [] + clen = [] + dr_idx = [] + + coff.append(jp2c.offset + 8) + clen.append(jp2c.length - (coff[0] - jp2c.offset)) + dr_idx.append(1) + + # Make the url box for this codestream. + url1 = DataEntryURLBox(0, [0, 0, 0], 'file://' + tfile1.name) + + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile2: + + j2k = Jp2k(self.j2kfile) + jp2_2 = j2k.wrap(tfile2.name) + + jp2c = [box for box in jp2_2.box if box.box_id == 'jp2c'][0] + coff.append(jp2c.offset + 8) + clen.append(jp2c.length - (coff[0] - jp2c.offset)) + dr_idx.append(2) + + # Make the url box for this codestream. + url2 = DataEntryURLBox(0, [0, 0, 0], 'file://' + tfile2.name) + + boxes = [JPEG2000SignatureBox(), + FileTypeBox(brand='jpx ', + compatibility_list=['jpx ', + 'jp2 ', 'jpxb']), + jp2_1.box[2]] + with tempfile.NamedTemporaryFile(suffix='.jpx') as tjpx: + for box in boxes: + box.write(tjpx) + + flst = FragmentListBox(coff, clen, dr_idx) + ftbl = FragmentTableBox([flst]) + ftbl.write(tjpx) + + boxes = [url1, url2] + dtbl = DataReferenceBox(data_entry_url_boxes=boxes) + dtbl.write(tjpx) + tjpx.flush() + + def test_jp2_with_jpx_box(self): + """If the brand is jp2, then no jpx boxes are allowed.""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + boxes = jp2.box + + boxes.append(glymur.jp2box.AssociationBox()) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + with self.assertRaises(IOError): + jp2.wrap(tfile.name, boxes=boxes) + + def test_ftbl(self): + """Write a fragment table box.""" + # Add a negative test where offset < 0 + # Add a negative test where length < 0 + # Add a negative test where ref > 0 but no data reference box. + # Add a negative test where more than one flst + # Add negative test where ftbl contained in a superbox. + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + + # The ftyp box must be modified to jpx. + boxes[1].brand = 'jpx ' + boxes[1].compatibility_list = ['jp2 ', 'jpxb'] + + offset = [89] + length = [1132288] + reference = [0] + flst = glymur.jp2box.FragmentListBox(offset, length, reference) + ftbl = glymur.jp2box.FragmentTableBox(box=[flst]) + boxes.append(ftbl) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + jpx = jp2.wrap(tfile.name, boxes=boxes) + + self.assertEqual(jpx.box[1].compatibility_list, ['jp2 ', 'jpxb']) + self.assertEqual(jpx.box[-1].box_id, 'ftbl') + self.assertEqual(jpx.box[-1].box[0].box_id, 'flst') + def test_jpxb_compatibility(self): """Wrap JP2 to JPX, state jpxb compatibility""" jp2 = Jp2k(self.jp2file) @@ -98,11 +193,47 @@ class TestJPXWrap(unittest.TestCase): self.assertEqual(jpx.box[-1].box[2].box_id, 'lbl ') self.assertEqual(jpx.box[-1].box[2].label, label) + def test_empty_data_reference(self): + """Empty data reference boxes can be created, but not written.""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + + boxes[1].brand = 'jpx ' + + dref = glymur.jp2box.DataReferenceBox() + boxes.append(dref) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + with self.assertRaises(IOError): + jp2.wrap(tfile.name, boxes=boxes) + + def test_deurl_child_of_dtbl(self): + """Data reference boxes can only contain data entry url boxes.""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + + ftyp = glymur.jp2box.FileTypeBox() + with self.assertRaises(IOError): + dref = glymur.jp2box.DataReferenceBox([ftyp]) + + # Try to get around it by appending the ftyp box after creation. + dref = glymur.jp2box.DataReferenceBox() + dref.DR.append(ftyp) + + boxes.append(dref) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + with self.assertRaises(IOError): + jp2.wrap(tfile.name, boxes=boxes) + def test_only_one_data_reference(self): """Data reference boxes cannot be inside a superbox .""" jp2 = Jp2k(self.jp2file) boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + # Have to make the ftyp brand jpx. + boxes[1].brand = 'jpx ' + flag = 0 version = (0, 0, 0) url = 'file:////usr/local/bin' @@ -113,16 +244,16 @@ class TestJPXWrap(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: with self.assertRaises(IOError): - jpx = jp2.wrap(tfile.name, boxes=boxes) + jp2.wrap(tfile.name, boxes=boxes) def test_lbl_at_top_level(self): """Label boxes can only be inside a asoc box .""" jp2 = Jp2k(self.jp2file) boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] - flag = 0 - version = (0, 0, 0) - url = 'file:////usr/local/bin' + # Have to make the ftyp brand jpx. + boxes[1].brand = 'jpx ' + lblb = glymur.jp2box.LabelBox('hi there') # Put it inside the jp2 header box. @@ -130,13 +261,16 @@ class TestJPXWrap(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: with self.assertRaises(IOError): - jpx = jp2.wrap(tfile.name, boxes=boxes) + jp2.wrap(tfile.name, boxes=boxes) - def test_data_reference_not_at_top_level(self): + def test_data_reference_in_subbox(self): """Data reference boxes cannot be inside a superbox .""" jp2 = Jp2k(self.jp2file) boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + # Have to make the ftyp brand jpx. + boxes[1].brand = 'jpx ' + flag = 0 version = (0, 0, 0) url = 'file:////usr/local/bin' @@ -148,13 +282,17 @@ class TestJPXWrap(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: with self.assertRaises(IOError): - jpx = jp2.wrap(tfile.name, boxes=boxes) + jp2.wrap(tfile.name, boxes=boxes) def test_jp2_to_jpx_sans_jp2_compatibility(self): """jp2 wrapped to jpx not including jp2 compatibility is wrong.""" jp2 = Jp2k(self.jp2file) boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + + # Have to make the ftyp brand jpx. + boxes[1].brand = 'jpx ' boxes[1].compatibility_list.append('jp2 ') + numbers = [0, 1] nlst = glymur.jp2box.NumberListBox(numbers) the_xml = ET.fromstring('0') @@ -164,7 +302,7 @@ class TestJPXWrap(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: with self.assertRaises(RuntimeError): - jpx = jp2.wrap(tfile.name, boxes=boxes) + jp2.wrap(tfile.name, boxes=boxes) def test_jp2_to_jpx_sans_jpx_brand(self): """Verify error when jp2 wrapped to jpx does not include jpx brand.""" @@ -180,7 +318,7 @@ class TestJPXWrap(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: with self.assertRaises(RuntimeError): - jpx = jp2.wrap(tfile.name, boxes=boxes) + jp2.wrap(tfile.name, boxes=boxes) @unittest.skipIf(os.name == "nt", "Temporary file issue on window.") @@ -188,11 +326,57 @@ class TestJPX(unittest.TestCase): """Test suite for other JPX boxes.""" def setUp(self): + self.jp2file = glymur.data.nemo() self.jpxfile = glymur.data.jpxfile() def tearDown(self): pass + def test_flst_lens_not_the_same(self): + """A fragment list box items must be the same length.""" + offset = [89] + length = [1132288] + reference = [0, 0] + flst = glymur.jp2box.FragmentListBox(offset, length, reference) + with self.assertRaises(IOError): + with tempfile.TemporaryFile() as tfile: + flst.write(tfile) + + def test_flst_offsets_not_positive(self): + """A fragment list box offsets must be positive.""" + offset = [0] + length = [1132288] + reference = [0] + flst = glymur.jp2box.FragmentListBox(offset, length, reference) + with self.assertRaises(IOError): + with tempfile.TemporaryFile() as tfile: + flst.write(tfile) + + def test_flst_lengths_not_positive(self): + """A fragment list box lengths must be positive.""" + offset = [89] + length = [0] + reference = [0] + flst = glymur.jp2box.FragmentListBox(offset, length, reference) + with self.assertRaises(IOError): + with tempfile.TemporaryFile() as tfile: + flst.write(tfile) + + def test_ftbl_boxes_empty(self): + """A fragment table box must have at least one child box.""" + ftbl = glymur.jp2box.FragmentTableBox() + with self.assertRaises(IOError): + with tempfile.TemporaryFile() as tfile: + ftbl.write(tfile) + + def test_ftbl_child_not_flst(self): + """A fragment table box can only contain a fragment list.""" + free = glymur.jp2box.FreeBox() + ftbl = glymur.jp2box.FragmentTableBox(box=[free]) + with self.assertRaises(IOError): + with tempfile.TemporaryFile() as tfile: + ftbl.write(tfile) + def test_jpx_rreq_mask_length_3(self): """There are some JPX files with rreq mask length of 3.""" jpx = Jp2k(self.jpxfile) @@ -202,12 +386,50 @@ class TestJPX(unittest.TestCase): self.assertEqual(jpx.box[2].standard_flag, (5, 42, 45, 2, 18, 19, 1, 8, 12, 31, 20)) + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") + def test_unknown_superbox(self): + """Verify that we can handle an unknown superbox.""" + with tempfile.NamedTemporaryFile(suffix='.jpx') as tfile: + with open(self.jpxfile, 'rb') as ifile: + tfile.write(ifile.read()) + + # Add the header for an unknwon superbox. + write_buffer = struct.pack('>I4s', 20, 'grp '.encode()) + tfile.write(write_buffer) + write_buffer = struct.pack('>I4sI', 12, 'free'.encode(), 0) + tfile.write(write_buffer) + tfile.flush() + + with self.assertWarns(UserWarning): + jpx = Jp2k(tfile.name) + self.assertEqual(jpx.box[-1].box_id, 'grp ') + self.assertEqual(jpx.box[-1].box[0].box_id, 'free') + def test_free_box(self): """Verify that we can handle a free box.""" j = Jp2k(self.jpxfile) self.assertEqual(j.box[16].box[0].box_id, 'free') self.assertEqual(type(j.box[16].box[0]), glymur.jp2box.FreeBox) + def test_data_reference_requires_dtbl(self): + """The existance of a data reference box requires a ftbl box as well.""" + flag = 0 + version = (0, 0, 0) + url1 = 'file:////usr/local/bin' + url2 = 'http://glymur.readthedocs.org' + jpx1 = glymur.Jp2k(self.jp2file) + boxes = jpx1.box + boxes[1].brand = 'jpx ' + + deurl1 = glymur.jp2box.DataEntryURLBox(flag, version, url1) + deurl2 = glymur.jp2box.DataEntryURLBox(flag, version, url2) + dref = glymur.jp2box.DataReferenceBox([deurl1, deurl2]) + boxes.append(dref) + + with tempfile.NamedTemporaryFile(suffix='.jpx') as tfile: + with self.assertRaises(IOError): + jpx1.wrap(tfile.name, boxes=boxes) + def test_dtbl(self): """Verify that we can interpret Data Reference boxes.""" # Copy the existing JPX file, add a data reference box onto the end. diff --git a/glymur/test/test_printing.py b/glymur/test/test_printing.py index 5fe3322..132d1ec 100644 --- a/glymur/test/test_printing.py +++ b/glymur/test/test_printing.py @@ -39,6 +39,7 @@ from .fixtures import text_gbr_27, text_gbr_33, text_gbr_34 class TestPrinting(unittest.TestCase): """Tests for verifying how printing works.""" def setUp(self): + self.jpxfile = glymur.data.jpxfile() self.jp2file = glymur.data.nemo() self.j2kfile = glymur.data.goodstuff() @@ -48,6 +49,31 @@ class TestPrinting(unittest.TestCase): def tearDown(self): pass + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") + def test_unknown_superbox(self): + """Verify that we can handle an unknown superbox.""" + with tempfile.NamedTemporaryFile(suffix='.jpx') as tfile: + with open(self.jpxfile, 'rb') as ifile: + tfile.write(ifile.read()) + + # Add the header for an unknwon superbox. + write_buffer = struct.pack('>I4s', 20, 'grp '.encode()) + tfile.write(write_buffer) + write_buffer = struct.pack('>I4sI', 12, 'free'.encode(), 0) + tfile.write(write_buffer) + tfile.flush() + + with self.assertWarns(UserWarning): + jpx = Jp2k(tfile.name) + glymur.set_printoptions(short=True) + with patch('sys.stdout', new=StringIO()) as fake_out: + print(jpx.box[-1]) + actual = fake_out.getvalue().strip() + lines = ['Unknown Box (grp ) @ (695609, 20)', + ' Free Box (free) @ (695617, 12)'] + expected = '\n'.join(lines) + self.assertEqual(actual, expected) + def test_printoptions_bad_argument(self): """Verify error when bad parameter to set_printoptions""" with self.assertRaises(TypeError): @@ -602,27 +628,7 @@ class TestPrinting(unittest.TestCase): with patch('sys.stdout', new=StringIO()) as fake_out: print(j.box[2]) actual = fake_out.getvalue().strip() - - lines = ['XML Box (xml ) @ (36, 439)', - ' ', - - ' ', - ' ' - + '2001-11-01T13:45:00.000-06:00' - + '', - - ' ' - + 'Professional 120 Image' - + '', - - ' ', - ' '] - expected = '\n'.join(lines) - self.assertEqual(actual, expected) + self.assertEqual(actual, fixtures.file1_xml) @unittest.skipIf(sys.hexversion < 0x03000000, "Only trusting python3 for printing non-ascii chars")