diff --git a/CHANGES.txt b/CHANGES.txt index de55628..0a8b277 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,8 +1,9 @@ Feb 03, 2014 - Removed support for Python 2.6. Added write support for - Palette and Component Mapping boxes. Added read support for JPX free, - number list, data reference, fragment table, and fragment list boxes. - Palette box now a 2D numpy array instead of a list of 1D arrays. - JP2 super box constructors now take optional box list argument. + JP2 Palette and Component Mapping boxes, JPX Association and + NumberList boxes. Added read support for JPX free, number list, + data reference, fragment table, and fragment list boxes. 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. diff --git a/glymur/jp2box.py b/glymur/jp2box.py index c51dd1b..83d768d 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -1369,6 +1369,21 @@ class AssociationBox(Jp2kBox): return box + def write(self, fptr): + """Write an association box to file. + """ + # Write the contained boxes, then come back and write the length. + orig_pos = fptr.tell() + fptr.write(struct.pack('>I', 0)) + fptr.write('asoc'.encode()) + for box in self.box: + box.write(fptr) + + end_pos = fptr.tell() + fptr.seek(orig_pos) + fptr.write(struct.pack('>I', end_pos - orig_pos)) + fptr.seek(end_pos) + class JP2HeaderBox(Jp2kBox): """Container for JP2 header box information. @@ -2180,15 +2195,19 @@ class NumberListBox(Jp2kBox): def __str__(self): msg = Jp2kBox.__str__(self) for j, association in enumerate(self.associations): + msg += '\n Association[{0}]: '.format(j) if association == 0: - msg += '\n Association[{0}]: the rendered result'.format(j) + msg += 'the rendered result' elif (association >> 24) == 1: idx = association & 0x00FFFFFF - msg += '\n Association[{0}]: Codestream {0} '.format(idx) + msg += 'Codestream {0}' + msg = msg.format(idx) elif (association >> 24) == 2: idx = association & 0x00FFFFFF - msg += '\n Association[{0}]: Compositing Layer {0}' - msg = msg.format(idx) + msg += 'Compositing Layer {0}' + msg = msg.format(j, idx) + else: + msg += 'unrecognized' return msg def __repr__(self): @@ -2219,6 +2238,16 @@ class NumberListBox(Jp2kBox): box = NumberListBox(lst, length=length, offset=offset) return box + def write(self, fptr): + """Write a NumberList box to file. + """ + fptr.write(struct.pack('>I', len(self.associations) * 4 + 8)) + fptr.write(self.box_id.encode()) + + fmt = '>' + 'I' * len(self.associations) + write_buffer = struct.pack(fmt, *self.associations) + fptr.write(write_buffer) + class XMLBox(Jp2kBox): """Container for XML box information. diff --git a/glymur/jp2k.py b/glymur/jp2k.py index 3fd9d21..ea99a44 100644 --- a/glymur/jp2k.py +++ b/glymur/jp2k.py @@ -37,6 +37,10 @@ from .lib import openjp2 as opj2 from . import version from .lib import c as libc +JP2_IDS = ['colr', 'cdef', 'cmap', 'jp2c', 'ftyp', 'ihdr', 'jp2h', 'jP ', + 'pclr', 'res ', 'resc', 'resd', 'xml ', 'ulst', 'uinf', 'url ', + 'uuid'] +JPX_IDS = ['asoc', 'nlst'] class Jp2k(Jp2kBox): """JPEG 2000 file. @@ -610,7 +614,7 @@ class Jp2k(Jp2kBox): jp2c = [box for box in self.box if box.box_id == 'jp2c'] jp2c = jp2c[0] - ofile.write(struct.pack('>I', jp2c.length + 8)) + ofile.write(struct.pack('>I', jp2c.length)) ofile.write('jp2c'.encode()) with open(self.filename, 'rb') as ifile: # Seek 8 bytes past the L, T fields to get to the @@ -1170,6 +1174,61 @@ def _validate_jp2_box_sequence(boxes): msg = "All color channels must be defined in the " msg += "channel definition box." raise IOError(msg) + + # The compatibility list must contain at a minimum 'jp2 '. + if 'jp2 ' not in boxes[1].compatibility_list: + msg = "The ftyp box must contain 'jp2 ' in the compatibility list." + raise IOError(msg) + + # JPX checks. + _asoc_check(boxes) + _jpx_brand(boxes, boxes[1].brand) + _jpx_compatibility(boxes, boxes[1].compatibility_list) + +def _jpx_brand(boxes, brand): + """ + If there is a JPX box then the brand must be 'jpx '. + """ + for box in boxes: + if box.box_id in JPX_IDS: + if brand != 'jpx ': + msg = "A JPX box requires that the file type box brand be " + msg += "'jpx '." + raise RuntimeError(msg) + if hasattr(box, 'box') != 0: + # Same set of checks on any child boxes. + _jpx_brand(box.box, brand) + +def _jpx_compatibility(boxes, compatibility_list): + """ + If there is a JPX box then the compatibility list must also contain 'jpx '. + """ + for box in boxes: + if box.box_id in JPX_IDS: + if 'jpx ' not in compatibility_list: + msg = "A JPX box requires that 'jpx ' be present in the " + msg += "ftype compatibility list." + raise RuntimeError(msg) + if hasattr(box, 'box') != 0: + # Same set of checks on any child boxes. + _jpx_compatibility(box.box, compatibility_list) + + +def _asoc_check(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. + _asoc_check(box.box) + def extract_image_cube(image): diff --git a/glymur/test/test_jp2box.py b/glymur/test/test_jp2box.py index d632de8..0a3b24a 100644 --- a/glymur/test/test_jp2box.py +++ b/glymur/test/test_jp2box.py @@ -553,7 +553,7 @@ class TestWrap(unittest.TestCase): self.verify_wrapped_raw(tfile.name) @unittest.skipIf(os.name == "nt", "Temporary file issue on window.") - def test_palette(self): + def test_jpx_to_jp2(self): """basic test for rewrapping a jpx file""" with warnings.catch_warnings(): # This file has a rreq mask length that we do not recognize. diff --git a/glymur/test/test_jp2box_jpx.py b/glymur/test/test_jp2box_jpx.py index 8c33be6..dbbabb7 100644 --- a/glymur/test/test_jp2box_jpx.py +++ b/glymur/test/test_jp2box_jpx.py @@ -9,14 +9,100 @@ import sys import tempfile import unittest import warnings +import xml.etree.cElementTree as ET import glymur from glymur import Jp2k +@unittest.skipIf(os.name == "nt", "Temporary file issue on window.") +class TestJPXWrap(unittest.TestCase): + """Test suite for wrapping JPX files.""" + + def setUp(self): + self.jp2file = glymur.data.nemo() + + raw_xml = b""" + + + 1 + 2008 + 141100 + + + + """ + with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tfile: + tfile.write(raw_xml) + tfile.flush() + self.xmlfile = tfile.name + + def tearDown(self): + os.unlink(self.xmlfile) + + def test_association_box(self): + """Wrap JP2 to JPX with asoc(nlst, xml)""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 5]] + + # The ftyp box must be modified to jpx with jp2 compatibility. + boxes[1].brand = 'jpx ' + boxes[1].compatibility_list = ['jp2 ', 'jpx '] + + numbers = (0, 1) + nlst = glymur.jp2box.NumberListBox(numbers) + the_xml = ET.fromstring('0') + xmlb = glymur.jp2box.XMLBox(xml=the_xml) + asoc = glymur.jp2box.AssociationBox([nlst, xmlb]) + boxes.append(asoc) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + jpx = jp2.wrap(tfile.name, boxes=boxes) + + self.assertEqual(jpx.box[1].compatibility_list, ['jp2 ', 'jpx ']) + self.assertEqual(jpx.box[-1].box_id, 'asoc') + self.assertEqual(jpx.box[-1].box[0].box_id, 'nlst') + self.assertEqual(jpx.box[-1].box[1].box_id, 'xml ') + self.assertEqual(jpx.box[-1].box[0].associations, numbers) + self.assertEqual(ET.tostring(jpx.box[-1].box[1].xml.getroot()), + b'0') + + 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, 5]] + boxes[1].compatibility_list.append('jp2 ') + numbers = [0, 1] + nlst = glymur.jp2box.NumberListBox(numbers) + the_xml = ET.fromstring('0') + xmlb = glymur.jp2box.XMLBox(xml=the_xml) + asoc = glymur.jp2box.AssociationBox([nlst, xmlb]) + boxes.append(asoc) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + with self.assertRaises(RuntimeError): + jpx = 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.""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 5]] + boxes[1].brand = 'jpx ' + numbers = [0, 1] + nlst = glymur.jp2box.NumberListBox(numbers) + the_xml = ET.fromstring('0') + xmlb = glymur.jp2box.XMLBox(xml=the_xml) + asoc = glymur.jp2box.AssociationBox([nlst, xmlb]) + boxes.append(asoc) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + with self.assertRaises(RuntimeError): + jpx = jp2.wrap(tfile.name, boxes=boxes) + + @unittest.skipIf(sys.hexversion < 0x03000000, "Warning assert on 2.x.") @unittest.skipIf(os.name == "nt", "Temporary file issue on window.") -class TestJPXOther(unittest.TestCase): +class TestJPX(unittest.TestCase): """Test suite for other JPX boxes.""" def setUp(self):