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):