initial commit
[fmms] / src / mms / message.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 #
4 # This library is free software, distributed under the terms of
5 # the GNU Lesser General Public License Version 2.
6 # See the COPYING.LESSER file included in this archive
7 #
8 # The docstrings in this module contain epytext markup; API documentation
9 # may be created by processing this file with epydoc: http://epydoc.sf.net
10 """
11 Library for MMS message creation, original by Francois Aucamp
12 Modified by Nick Leppänen Larsson for use in Maemo5/Fremantle on the Nokia N900.
13
14 @author: Francois Aucamp <faucamp@csir.co.za>
15 @author: Nick Leppänen Larsson <frals@frals.se>
16 @license: GNU LGPL
17 """
18 """ High-level MMS-message creation/manipulation classes """
19
20 import xml.dom.minidom
21 import os
22 import mimetypes
23 import array
24 import random
25
26 class MMSMessage:
27     """ An MMS message
28     
29     @note: References used in this class: [1][2][3][4][5]
30     """
31     def __init__(self, noHeaders=None):
32         self.noHeaders = noHeaders
33         transactionid = random.randint(0,100000)
34         self._pages = []
35         self._dataParts = []
36         self._metaTags = {}
37         if self.noHeaders == None:
38                 self.headers = {'Message-Type' : 'm-send-req',
39                                 'Transaction-Id' : transactionid,
40                                 'MMS-Version' : '1.0',
41                                 'Content-Type' : ('application/vnd.wap.multipart.mixed', {'Start': '<smil>', 'Type': 'application/smil'})}
42         else:
43                 self.headers = {}
44         self.width = 320
45         self.height = 240
46         self.transactionID = transactionid
47         self.subject = 'Subject'
48         
49     # contentType property
50     @property
51     def contentType(self):
52         """ Returns the string representation of this data part's
53         "Content-Type" header. No parameter information is returned;
54         to get that, access the "Content-Type" header directly (which has a
55         tuple value)from the message's C{headers} attribute.
56         
57         This is equivalent to calling DataPart.headers['Content-Type'][0]
58         """
59         return self.headers['Content-Type'][0]
60
61     def addPage(self, page):
62         """ Adds a single page/slide (MMSMessagePage object) to the message 
63         
64         @param page: The message slide/page to add
65         @type page: MMSMessagPage
66         """
67         if self.contentType != 'application/vnd.wap.multipart.related':
68             self.headers['Content-Type'] = ('application/vnd.wap.multipart.related', {'Start': '<smil>', 'Type': 'application/smil'})
69             #self.headers['Content-Type'] = ('application/vnd.wap.multipart.related', {})
70         self._pages.append(page)
71     
72     @property
73     def pages(self):
74         """ Returns a list of all the pages in this message """
75         return self._pages
76         
77     def addDataPart(self, dataPart):
78         """ Adds a single data part (DataPart object) to the message, without
79         connecting it to a specific slide/page in the message.
80         
81         A data part encapsulates some form of attachment, e.g. an image, audio
82         etc.
83         
84         @param dataPart: The data part to add
85         @type dataPart: DataPart
86         
87         @note: It is not necessary to explicitly add data parts to the message
88                using this function if "addPage" is used; this method is mainly
89                useful if you want to create MMS messages without SMIL support,
90                i.e. messages of type "application/vnd.wap.multipart.mixed"
91         """
92         self._dataParts.append(dataPart)
93     
94     @property
95     def dataParts(self):
96         """ Returns a list of all the data parts in this message, including
97         data parts that were added to slides in this message """
98         parts = []
99         if len(self._pages) > 0:
100             parts.append(self.smil())        
101             for slide in self._mmsMessage._pages:
102                 parts.extend(slide.dataParts())
103         parts.extend(self._dataParts)
104         return parts
105         
106     
107     def smil(self):
108         """ Returns the text of the message's SMIL file """
109         impl = xml.dom.minidom.getDOMImplementation()
110         smilDoc = impl.createDocument(None, "smil", None)
111   
112         # Create the SMIL header
113         headNode = smilDoc.createElement('head')
114         # Add metadata to header
115         for tagName in self._metaTags:
116             metaNode = smilDoc.createElement('meta')
117             metaNode.setAttribute(tagName, self._metaTags[tagName])
118             headNode.appendChild(metaNode)
119         # Add layout info to header
120         layoutNode = smilDoc.createElement('layout')
121         rootLayoutNode = smilDoc.createElement('root-layout')
122         rootLayoutNode.setAttribute('width', str(self.width))
123         rootLayoutNode.setAttribute('height', str(self.height))
124         layoutNode.appendChild(rootLayoutNode)
125         #for regionID, left, top, width, height in (('Image', '0', '0', '176', '144'), ('Text', '176', '144', '176', '76')):
126         (regionID, left, top, width, height) = ('Text', '0', '0', '320', '240')
127         regionNode = smilDoc.createElement('region')
128         regionNode.setAttribute('id', regionID)
129         regionNode.setAttribute('left', left)
130         regionNode.setAttribute('top', top)
131         regionNode.setAttribute('width', width)
132         regionNode.setAttribute('height', height)
133         layoutNode.appendChild(regionNode)
134         headNode.appendChild(layoutNode)
135         smilDoc.documentElement.appendChild(headNode)
136         
137         # Create the SMIL body
138         bodyNode = smilDoc.createElement('body')
139         # Add pages to body
140         for page in self._pages:
141             parNode = smilDoc.createElement('par')
142             parNode.setAttribute('duration', str(page.duration))
143             # Add the page content information
144             if page.image != None:
145                 #TODO: catch unpack exception
146                 part, begin, end = page.image
147                 if 'Content-Location' in part.headers:
148                     src = part.headers['Content-Location']
149                 elif 'Content-ID' in part.headers:
150                     src = part.headers['Content-ID']
151                 else:
152                     src = part.data
153                 imageNode = smilDoc.createElement('img')
154                 imageNode.setAttribute('src', src)
155                 imageNode.setAttribute('region', 'Image')
156                 if begin > 0 or end > 0:
157                     if end > page.duration:
158                         end = page.duration
159                     imageNode.setAttribute('begin', str(begin))
160                     imageNode.setAttribute('end', str(end))
161                 parNode.appendChild(imageNode)
162             if page.text != None:
163                 part, begin, end = page.text
164                 #src = part.data
165                 textNode = smilDoc.createElement('text')
166                 #textNode.setAttribute('src', src)
167                 textNode.setAttribute('src', 'text.txt')
168                 textNode.setAttribute('region', 'Text')
169                 if begin > 0 or end > 0:
170                     if end > page.duration:
171                         end = page.duration
172                     textNode.setAttribute('begin', str(begin))
173                     textNode.setAttribute('end', str(end))
174                 parNode.appendChild(textNode)
175             if page.audio != None:
176                 part, begin, end = page.audio
177                 if 'Content-Location' in part.headers:
178                     src = part.headers['Content-Location']
179                 elif 'Content-ID' in part.headers:
180                     src = part.headers['Content-ID']
181                     pass
182                 else:
183                     src = part.data
184                 audioNode = smilDoc.createElement('audio')
185                 audioNode.setAttribute('src', src)
186                 if begin > 0 or end > 0:
187                     if end > page.duration:
188                         end = page.duration
189                     audioNode.setAttribute('begin', str(begin)) 
190                     audioNode.setAttribute('end', str(end))
191                 parNode.appendChild(textNode)
192                 parNode.appendChild(audioNode)
193             bodyNode.appendChild(parNode)
194         smilDoc.documentElement.appendChild(bodyNode)
195         
196         return smilDoc.documentElement.toprettyxml()
197
198
199     def encode(self):
200         """ Convenience funtion that binary-encodes this MMS message
201         
202         @note: This uses the C{mms_pdu.MMSEncoder} class internally
203         
204         @return: The binary-encode MMS data, as an array of bytes
205         @rtype array.array('B')
206         """
207         import mms_pdu
208         encoder = mms_pdu.MMSEncoder(self.noHeaders)
209         return encoder.encode(self)
210
211
212     def toFile(self, filename):
213         """ Convenience funtion that writes this MMS message to disk in 
214         binary-encoded form.
215         
216         @param filename: The name of the file in which to store the message
217                          data
218         @type filename: str
219     
220         @note: This uses the C{mms_pdu.MMSEncoder} class internally
221         
222         @return: The binary-encode MMS data, as an array of bytes
223         @rtype array.array('B')
224         """
225         f = open(filename, 'wb')
226         self.encode().tofile(f)
227         f.close()
228     
229     @staticmethod
230     def fromFile(filename):
231         """ Convenience static funtion that loads the specified MMS message
232         file from disk, decodes its data, and returns a new MMSMessage object,
233         which can then be manipulated and re-encoded, for instance.
234         
235         @param filename: The name of the file to load
236         @type filename: str
237     
238         @note: This uses the C{mms_pdu.MMSDecoder} class internally
239         """
240         import mms_pdu
241         decoder = mms_pdu.MMSDecoder(os.path.dirname(filename))
242         return decoder.decodeFile(filename)
243
244     @staticmethod
245     def fromData(inputdata):
246         """ Convenience static funtion that loads the specified MMS message
247         file from input, decodes its data, and returns a new MMSMessage object,
248         which can then be manipulated and re-encoded, for instance.
249         
250         @param input: Input data to decode
251         @type input: str
252     
253         @note: This uses the C{mms_pdu.MMSDecoder} class internally
254         """
255         import mms_pdu
256         decoder = mms_pdu.MMSDecoder()
257         data = array.array('B')
258         data.fromstring(inputdata)
259         return decoder.decodeData(data)
260
261 class MMSMessagePage:
262     """ A single page (or "slide") in an MMS Message. 
263     
264     In order to ensure that the MMS message can be correctly displayed by most
265     terminals, each page's content is limited to having 1 image, 1 audio clip
266     and 1 block of text, as stated in [1].
267     
268     @note: The default slide duration is set to 4 seconds; use setDuration()
269            to change this.
270     
271     @note: References used in this class: [1]
272     """
273     def __init__(self):
274         self.duration = 4000
275         self.image = None
276         self.audio = None
277         self.text = None
278     
279     @property
280     def dataParts(self):
281         """ Returns a list of the data parts in this slide """
282         parts = []
283         for part in (self.image, self.audio, self.text):
284             if part != None:
285                 parts.append(part)
286         return parts
287
288     def numberOfParts(self):
289         """ This function calculates the amount of data "parts" (or elements)
290         in this slide.
291             
292         @return: The number of data parts in this slide
293         @rtype: int
294         """
295         numParts = 0
296         for item in (self.image, self.audio, self.text):
297             if item != None:
298                 numParts += 1
299         return numParts
300         
301     #TODO: find out what the "ref" element in SMIL does (seen in conformance doc)
302     
303     #TODO: add support for "alt" element; also make sure what it does
304     def addImage(self, filename, timeBegin=0, timeEnd=0):
305         """ Adds an image to this slide.
306         @param filename: The name of the image file to add. Supported formats
307                          are JPEG, GIF and WBMP.
308         @type filename: str
309         @param timeBegin: The time (in milliseconds) during the duration of
310                           this slide to begin displaying the image. If this is
311                           0 or less, the image will be displayed from the
312                           moment the slide is opened.
313         @type timeBegin: int
314         @param timeEnd: The time (in milliseconds) during the duration of this
315                         slide at which to stop showing (i.e. hide) the image.
316                         If this is 0 or less, or if it is greater than the
317                         actual duration of this slide, it will be shown until
318                         the next slide is accessed.
319         @type timeEnd: int
320         
321         @raise TypeError: An inappropriate variable type was passed in of the
322                           parameters
323         """
324         if type(filename) != str or type(timeBegin) != type(timeEnd) != int:
325             raise TypeError
326         if not os.path.isfile(filename):
327             raise OSError
328         if timeEnd > 0 and timeEnd < timeBegin:
329             raise ValueError, 'timeEnd cannot be lower than timeBegin'
330         self.image = (DataPart(filename), timeBegin, timeEnd)
331     
332     def addAudio(self, filename, timeBegin=0, timeEnd=0):
333         """ Adds an audio clip to this slide.
334         @param filename: The name of the audio file to add. Currently the only
335                          supported format is AMR.
336         @type filename: str
337         @param timeBegin: The time (in milliseconds) during the duration of 
338                           this slide to begin playback of the audio clip. If
339                           this is 0 or less, the audio clip will be played the
340                           moment the slide is opened.
341         @type timeBegin: int
342         @param timeEnd: The time (in milliseconds) during the duration of this
343                         slide at which to stop playing (i.e. mute) the audio
344                         clip. If this is 0 or less, or if it is greater than
345                         the actual duration of this slide, the entire audio
346                         clip will be played, or until the next slide is
347                         accessed.
348         @type timeEnd: int
349         
350         @raise TypeError: An inappropriate variable type was passed in of the
351                           parameters
352         """
353         if type(filename) != str or type(timeBegin) != type(timeEnd) != int:
354             raise TypeError
355         if not os.path.isfile(filename):
356             raise OSError
357         if timeEnd > 0 and timeEnd < timeBegin:
358             raise ValueError, 'timeEnd cannot be lower than timeBegin'
359         self.audio = (DataPart(filename), timeBegin, timeEnd)
360     
361     def addText(self, text, timeBegin=0, timeEnd=0):
362         """ Adds a block of text to this slide.
363         @param text: The text to add to the slide.
364         @type text: str
365         @param timeBegin: The time (in milliseconds) during the duration of
366                           this slide to begin displaying the text. If this is
367                           0 or less, the text will be displayed from the
368                           moment the slide is opened.
369         @type timeBegin: int
370         @param timeEnd: The time (in milliseconds) during the duration of this
371                         slide at which to stop showing (i.e. hide) the text.
372                         If this is 0 or less, or if it is greater than the
373                         actual duration of this slide, it will be shown until
374                         the next slide is accessed.
375         @type timeEnd: int
376         
377         @raise TypeError: An inappropriate variable type was passed in of the
378                           parameters
379         """
380         if type(text) != str or type(timeBegin) != type(timeEnd) != int:
381             raise TypeError
382         if timeEnd > 0 and timeEnd < timeBegin:
383             raise ValueError, 'timeEnd cannot be lower than timeBegin'
384         tData = DataPart()
385         tData.setText(text)
386         self.text = (tData, timeBegin, timeEnd)
387     
388     def setDuration(self, duration):
389         """ Sets the maximum duration of this slide (i.e. how long this slide
390         should be displayed)
391         
392         @param duration: the maxium slide duration, in milliseconds
393         @type duration: int
394         
395         @raise TypeError: <duration> must be an integer
396         @raise ValueError: the requested duration is invalid (must be a
397                            non-zero, positive integer)
398         """
399         if type(duration) != int:
400             raise TypeError
401         elif duration < 1:
402             raise ValueError, 'duration may not be 0 or negative'
403         self.duration = duration
404
405 class DataPart:
406     """ This class represents a data entry in the MMS body.
407     
408     A DataPart objectencapsulates any data content that is to be added to the
409     MMS (e.g. an image file, raw image data, audio clips, text, etc).
410     
411     A DataPart object can be queried using the Python built-in C{len()}
412     function.
413     
414     This encapsulation allows custom header/parameter information to be set
415     for each data entry in the MMS. Refer to [5] for more information on
416     these.
417     """
418     def __init__(self, srcFilename=None):
419         """ @param srcFilename: If specified, load the content of the file
420                                 with this name
421             @type srcFilename: str
422         """
423         #self.contentTypeParameters = {}
424         self.headers = {'Content-Type': ('application/octet-stream', {})}
425         self._filename = None
426         self._data = None
427         self._part = "<fMMSpart" + str(random.randint(0,10000)) + ">"
428         if srcFilename != None:
429             self.fromFile(srcFilename)
430     
431     # contentType property
432     def _getContentType(self):
433         """ Returns the string representation of this data part's
434         "Content-Type" header. No parameter information is returned;
435         to get that, access the "Content-Type" header directly (which has a
436         tuple value)from this part's C{headers} attribute.
437         
438         This is equivalent to calling DataPart.headers['Content-Type'][0]
439         """
440         return self.headers['Content-Type'][0]
441     def _setContentType(self, value):
442         """ Convenience method that sets the content type string, with no
443         parameters """
444         self.headers['Content-Type'] = (value, {})
445         contentType = property(_getContentType, _setContentType) 
446     
447     def fromFile(self, filename):
448         """ Load the data contained in the specified file
449         
450         @note: This function clears any previously-set header entries.
451         
452         @param filename: The name of the file to open
453         @type filename: str
454         
455         @raises OSError: The filename is invalid
456         """
457         if not os.path.isfile(filename):
458             raise OSError, 'The file "%s" does not exist.' % filename
459         # Clear any headers that are currently set
460         self.headers = {}
461         self._data = None
462         self.headers['Content-Location'] = os.path.basename(filename)  
463         #self.contentType = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
464         #print mimetypes.guess_type(filename)[0]
465         if mimetypes.guess_type(filename)[0] == 'text/plain':
466                 self.headers['Content-Type'] = ('text/plain', {'Charset': 'utf-8', 'Name': os.path.basename(filename)})
467         else:
468                 self.headers['Content-Type'] = (mimetypes.guess_type(filename)[0] or 'application/octet-stream', {'Name': os.path.basename(filename)})
469         #self.headers['Content-Type'] = (mimetypes.guess_type(filename)[0] or 'application/octet-stream', {})
470         #self.headers['Content-ID'] = self._part
471         self._filename = filename
472     
473     def setData(self, data, contentType, ctParameters={}):
474         """ Explicitly set the data contained by this part
475         
476         @note: This function clears any previously-set header entries.
477         
478         @param data: The data to hold
479         @type data: str
480         @param contentType: The MIME content type of the specified data
481         @type contentType: str
482         @param ctParameters: A dictionary containing any content type header
483                              parmaters to add, in the format:
484                              C{{<parameter_name> : <parameter_value>}}
485         @type ctParameters: dict
486         """
487         self.headers = {}
488         self._filename = None
489         # self._data = data
490         # self._data = self._part + "\0"
491         self._data = data
492         if contentType == "application/smil":
493                 #self.headers['Content-Type'] = (contentType, {'Charset': 'utf-8', 'Name': 'smil.smil'})
494                 self.headers['Content-Type'] = (contentType, {})
495         else:
496                 self.headers['Content-Type'] = (contentType, ctParameters)
497         self.headers['Content-ID'] = (self._part)
498         
499     def setText(self, text):
500         """ Convenience wrapper method for setData()
501         
502         This method sets the DataPart object to hold the specified text
503         string, with MIME content type "text/plain".
504         
505         @param text: The text to hold
506         @type text: str
507         """
508         self.setData(text, 'text/plain', {'Charset': 'utf-8', 'Name': 'text.txt'})
509     
510     def __len__(self):
511         """ Provides the length of the data encapsulated by this object """
512         if self._filename != None:
513             return int(os.stat(self._filename)[6])
514         else:
515             return len(self.data)
516     
517     @property
518     def data(self):
519         """ @return: the data of this part
520         @rtype: str
521         """
522         if self._data != None:
523             if type(self._data) == array.array:
524                 self._data = self._data.tostring()
525             return self._data
526         elif self._filename != None:
527             f = open(self._filename, 'r')
528             self._data = f.read()
529             f.close()
530             return self._data
531         else:
532             return ''