1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 # This module is based on OFS.Image originaly copyrighted as:
5 # Copyright (c) 2002 Zope Corporation and Contributors. All Rights Reserved.
7 # This software is subject to the provisions of the Zope Public License,
8 # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
9 # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
10 # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
11 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
12 # FOR A PARTICULAR PURPOSE
14 ##############################################################################
19 from cgi
import escape
20 from cStringIO
import StringIO
21 from mimetools
import choose_boundary
24 from AccessControl
.Permissions
import change_images_and_files
25 from AccessControl
.Permissions
import view_management_screens
26 from AccessControl
.Permissions
import view
as View
27 from AccessControl
.Permissions
import ftp_access
28 from AccessControl
.Permissions
import delete_objects
29 from AccessControl
.Role
import RoleManager
30 from AccessControl
.SecurityInfo
import ClassSecurityInfo
31 from Acquisition
import Implicit
32 from App
.class_init
import InitializeClass
33 from App
.special_dtml
import DTMLFile
34 from DateTime
.DateTime
import DateTime
35 from Persistence
import Persistent
36 from webdav
.common
import rfc1123_date
37 from webdav
.interfaces
import IWriteLock
38 from webdav
.Lockable
import ResourceLockedError
39 from ZPublisher
import HTTPRangeSupport
40 from ZPublisher
.HTTPRequest
import FileUpload
41 from ZPublisher
.Iterators
import filestream_iterator
42 from zExceptions
import Redirect
43 from zope
.contenttype
import guess_content_type
44 from zope
.interface
import implementedBy
45 from zope
.interface
import implements
47 from OFS
.Cache
import Cacheable
48 from OFS
.PropertyManager
import PropertyManager
49 from OFS
.SimpleItem
import Item_w__name__
51 from zope
.event
import notify
52 from zope
.lifecycleevent
import ObjectModifiedEvent
53 from zope
.lifecycleevent
import ObjectCreatedEvent
55 from ZODB
.blob
import Blob
60 manage_addFileForm
= DTMLFile('dtml/imageAdd',
65 def manage_addFile(self
, id, file='', title
='', precondition
='',
66 content_type
='', REQUEST
=None):
67 """Add a new File object.
69 Creates a new File object 'id' with the contents of 'file'"""
73 content_type
= str(content_type
)
74 precondition
= str(precondition
)
76 id, title
= cookId(id, title
, file)
79 self
._setObject
(id, File(id,title
,file,content_type
, precondition
))
81 newFile
= self
._getOb
(id)
82 notify(ObjectCreatedEvent(newFile
))
84 if REQUEST
is not None:
85 REQUEST
['RESPONSE'].redirect(self
.absolute_url()+'/manage_main')
88 class File(Persistent
, Implicit
, PropertyManager
,
89 RoleManager
, Item_w__name__
, Cacheable
):
90 """A File object is a content object for arbitrary files."""
92 implements(implementedBy(Persistent
),
93 implementedBy(Implicit
),
94 implementedBy(PropertyManager
),
95 implementedBy(RoleManager
),
96 implementedBy(Item_w__name__
),
97 implementedBy(Cacheable
),
99 HTTPRangeSupport
.HTTPRangeInterface
,
101 meta_type
='Blob File'
103 security
= ClassSecurityInfo()
104 security
.declareObjectProtected(View
)
109 manage_editForm
=DTMLFile('dtml/fileEdit',globals(),
110 Kind
='File',kind
='file')
111 manage_editForm
._setName
('manage_editForm')
113 security
.declareProtected(view_management_screens
, 'manage')
114 security
.declareProtected(view_management_screens
, 'manage_main')
115 manage
=manage_main
=manage_editForm
116 manage_uploadForm
=manage_editForm
120 {'label':'Edit', 'action':'manage_main',
121 'help':('OFSP','File_Edit.stx')},
122 {'label':'View', 'action':'',
123 'help':('OFSP','File_View.stx')},
125 + PropertyManager
.manage_options
126 + RoleManager
.manage_options
127 + Item_w__name__
.manage_options
128 + Cacheable
.manage_options
131 _properties
=({'id':'title', 'type': 'string'},
132 {'id':'content_type', 'type':'string'},
135 def __init__(self
, id, title
, file, content_type
='', precondition
=''):
138 self
.precondition
=precondition
139 self
.uploaded_filename
= cookId('', '', file)[0]
142 content_type
=self
._get
_content
_type
(file, id, content_type
)
143 self
.update_data(file, content_type
)
145 security
.declarePrivate('save')
146 def save(self
, file):
147 bf
= self
.bdata
.open('w')
148 bf
.write(file.read())
149 self
.size
= bf
.tell()
152 security
.declarePrivate('open')
153 def open(self
, mode
='r'):
154 bf
= self
.bdata
.open(mode
)
157 security
.declarePrivate('updateSize')
158 def updateSize(self
, size
=None):
162 self
.size
= bf
.tell()
167 def _getLegacyData(self
) :
168 warn("Accessing 'data' attribute may be inefficient with "
169 "this blob based file. You should refactor your product "
170 "by accessing data like: "
171 "f = self.open('r') "
173 DeprecationWarning, stacklevel
=2)
179 def _setLegacyData(self
, data
) :
180 warn("Accessing 'data' attribute may be inefficient with "
181 "this blob based file. You should refactor your product "
182 "by accessing data like: "
183 "f = self.save(data)",
184 DeprecationWarning, stacklevel
=2)
185 if isinstance(data
, str) :
192 data
= property(_getLegacyData
, _setLegacyData
,
193 "Data Legacy attribute to ensure compatibility "
194 "with derived classes that access data by this way.")
199 def _if_modified_since_request_handler(self
, REQUEST
, RESPONSE
):
200 # HTTP If-Modified-Since header handling: return True if
201 # we can handle this request by returning a 304 response
202 header
=REQUEST
.get_header('If-Modified-Since', None)
203 if header
is not None:
204 header
=header
.split( ';')[0]
205 # Some proxies seem to send invalid date strings for this
206 # header. If the date string is not valid, we ignore it
207 # rather than raise an error to be generally consistent
208 # with common servers such as Apache (which can usually
209 # understand the screwy date string as a lucky side effect
210 # of the way they parse it).
211 # This happens to be what RFC2616 tells us to do in the face of an
213 try: mod_since
=long(DateTime(header
).timeTime())
214 except: mod_since
=None
215 if mod_since
is not None:
217 last_mod
= long(self
._p
_mtime
)
220 if last_mod
> 0 and last_mod
<= mod_since
:
221 RESPONSE
.setHeader('Last-Modified',
222 rfc1123_date(self
._p
_mtime
))
223 RESPONSE
.setHeader('Content-Type', self
.content_type
)
224 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
225 RESPONSE
.setStatus(304)
228 def _range_request_handler(self
, REQUEST
, RESPONSE
):
229 # HTTP Range header handling: return True if we've served a range
230 # chunk out of our data.
231 range = REQUEST
.get_header('Range', None)
232 request_range
= REQUEST
.get_header('Request-Range', None)
233 if request_range
is not None:
234 # Netscape 2 through 4 and MSIE 3 implement a draft version
235 # Later on, we need to serve a different mime-type as well.
236 range = request_range
237 if_range
= REQUEST
.get_header('If-Range', None)
238 if range is not None:
239 ranges
= HTTPRangeSupport
.parseRange(range)
241 if if_range
is not None:
242 # Only send ranges if the data isn't modified, otherwise send
243 # the whole object. Support both ETags and Last-Modified dates!
244 if len(if_range
) > 1 and if_range
[:2] == 'ts':
246 if if_range
!= self
.http__etag():
247 # Modified, so send a normal response. We delete
248 # the ranges, which causes us to skip to the 200
253 date
= if_range
.split( ';')[0]
254 try: mod_since
=long(DateTime(date
).timeTime())
255 except: mod_since
=None
256 if mod_since
is not None:
258 last_mod
= long(self
._p
_mtime
)
261 if last_mod
> mod_since
:
262 # Modified, so send a normal response. We delete
263 # the ranges, which causes us to skip to the 200
268 # Search for satisfiable ranges.
270 for start
, end
in ranges
:
271 if start
< self
.size
:
276 RESPONSE
.setHeader('Content-Range',
277 'bytes */%d' % self
.size
)
278 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
279 RESPONSE
.setHeader('Last-Modified',
280 rfc1123_date(self
._p
_mtime
))
281 RESPONSE
.setHeader('Content-Type', self
.content_type
)
282 RESPONSE
.setHeader('Content-Length', self
.size
)
283 RESPONSE
.setStatus(416)
286 ranges
= HTTPRangeSupport
.expandRanges(ranges
, self
.size
)
289 # Easy case, set extra header and return partial set.
290 start
, end
= ranges
[0]
293 RESPONSE
.setHeader('Last-Modified',
294 rfc1123_date(self
._p
_mtime
))
295 RESPONSE
.setHeader('Content-Type', self
.content_type
)
296 RESPONSE
.setHeader('Content-Length', size
)
297 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
298 RESPONSE
.setHeader('Content-Range',
299 'bytes %d-%d/%d' % (start
, end
- 1, self
.size
))
300 RESPONSE
.setStatus(206) # Partial content
304 RESPONSE
.write(bf
.read(size
))
309 boundary
= choose_boundary()
311 # Calculate the content length
312 size
= (8 + len(boundary
) + # End marker length
313 len(ranges
) * ( # Constant lenght per set
314 49 + len(boundary
) + len(self
.content_type
) +
315 len('%d' % self
.size
)))
316 for start
, end
in ranges
:
317 # Variable length per set
318 size
= (size
+ len('%d%d' % (start
, end
- 1)) +
322 # Some clients implement an earlier draft of the spec, they
323 # will only accept x-byteranges.
324 draftprefix
= (request_range
is not None) and 'x-' or ''
326 RESPONSE
.setHeader('Content-Length', size
)
327 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
328 RESPONSE
.setHeader('Last-Modified',
329 rfc1123_date(self
._p
_mtime
))
330 RESPONSE
.setHeader('Content-Type',
331 'multipart/%sbyteranges; boundary=%s' % (
332 draftprefix
, boundary
))
333 RESPONSE
.setStatus(206) # Partial content
338 # # The Pdata map allows us to jump into the Pdata chain
339 # # arbitrarily during out-of-order range searching.
341 # pdata_map[0] = data
343 for start
, end
in ranges
:
344 RESPONSE
.write('\r\n--%s\r\n' % boundary
)
345 RESPONSE
.write('Content-Type: %s\r\n' %
348 'Content-Range: bytes %d-%d/%d\r\n\r\n' % (
349 start
, end
- 1, self
.size
))
354 RESPONSE
.write(bf
.read(size
))
358 RESPONSE
.write('\r\n--%s--\r\n' % boundary
)
361 security
.declareProtected(View
, 'index_html')
362 def index_html(self
, REQUEST
, RESPONSE
):
364 The default view of the contents of a File or Image.
366 Returns the contents of the file or image. Also, sets the
367 Content-Type HTTP header to the objects content type.
370 if self
._if
_modified
_since
_request
_handler
(REQUEST
, RESPONSE
):
371 # we were able to handle this by returning a 304
372 # unfortunately, because the HTTP cache manager uses the cache
373 # API, and because 304 responses are required to carry the Expires
374 # header for HTTP/1.1, we need to call ZCacheable_set here.
375 # This is nonsensical for caches other than the HTTP cache manager
377 self
.ZCacheable_set(None)
380 if self
.precondition
and hasattr(self
, str(self
.precondition
)):
381 # Grab whatever precondition was defined and then
382 # execute it. The precondition will raise an exception
383 # if something violates its terms.
384 c
=getattr(self
, str(self
.precondition
))
385 if hasattr(c
,'isDocTemp') and c
.isDocTemp
:
386 c(REQUEST
['PARENTS'][1],REQUEST
)
390 if self
._range
_request
_handler
(REQUEST
, RESPONSE
):
391 # we served a chunk of content in response to a range request.
394 RESPONSE
.setHeader('Last-Modified', rfc1123_date(self
._p
_mtime
))
395 RESPONSE
.setHeader('Content-Type', self
.content_type
)
396 RESPONSE
.setHeader('Content-Length', self
.size
)
397 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
399 if self
.ZCacheable_isCachingEnabled():
400 result
= self
.ZCacheable_get(default
=None)
401 if result
is not None:
402 # We will always get None from RAMCacheManager and HTTP
403 # Accelerated Cache Manager but we will get
404 # something implementing the IStreamIterator interface
405 # from a "FileCacheManager"
408 self
.ZCacheable_set(None)
411 chunk
= bf
.read(CHUNK_SIZE
)
413 RESPONSE
.write(chunk
)
414 chunk
= bf
.read(CHUNK_SIZE
)
418 security
.declareProtected(View
, 'view_image_or_file')
419 def view_image_or_file(self
, URL1
):
421 The default view of the contents of the File or Image.
425 security
.declareProtected(View
, 'PrincipiaSearchSource')
426 def PrincipiaSearchSource(self
):
427 """ Allow file objects to be searched.
429 if self
.content_type
.startswith('text/'):
436 security
.declarePrivate('update_data')
437 def update_data(self
, file, content_type
=None):
438 if isinstance(file, unicode):
439 raise TypeError('Data can only be str or file-like. '
440 'Unicode objects are expressly forbidden.')
441 elif isinstance(file, str) :
447 if content_type
is not None: self
.content_type
=content_type
449 self
.ZCacheable_invalidate()
450 self
.ZCacheable_set(None)
451 self
.http__refreshEtag()
453 security
.declareProtected(change_images_and_files
, 'manage_edit')
454 def manage_edit(self
, title
, content_type
, precondition
='',
455 filedata
=None, REQUEST
=None):
457 Changes the title and content type attributes of the File or Image.
459 if self
.wl_isLocked():
460 raise ResourceLockedError
, "File is locked via WebDAV"
462 self
.title
=str(title
)
463 self
.content_type
=str(content_type
)
464 if precondition
: self
.precondition
=str(precondition
)
465 elif self
.precondition
: del self
.precondition
466 if filedata
is not None:
467 self
.update_data(filedata
, content_type
)
469 self
.ZCacheable_invalidate()
471 notify(ObjectModifiedEvent(self
))
474 message
="Saved changes."
475 return self
.manage_main(self
,REQUEST
,manage_tabs_message
=message
)
477 security
.declareProtected(change_images_and_files
, 'manage_upload')
478 def manage_upload(self
,file='',REQUEST
=None):
480 Replaces the current contents of the File or Image object with file.
482 The file or images contents are replaced with the contents of 'file'.
484 if self
.wl_isLocked():
485 raise ResourceLockedError
, "File is locked via WebDAV"
487 content_type
=self
._get
_content
_type
(file, self
.__name
__,
488 'application/octet-stream')
489 self
.update_data(file, content_type
)
492 message
="Saved changes."
493 return self
.manage_main(self
,REQUEST
,manage_tabs_message
=message
)
495 def _get_content_type(self
, file, id, content_type
=None):
496 headers
=getattr(file, 'headers', None)
497 if headers
and headers
.has_key('content-type'):
498 content_type
=headers
['content-type']
500 name
= getattr(file, 'filename', self
.uploaded_filename
) or id
501 content_type
, enc
=guess_content_type(name
, '', content_type
)
504 security
.declareProtected(delete_objects
, 'DELETE')
506 security
.declareProtected(change_images_and_files
, 'PUT')
507 def PUT(self
, REQUEST
, RESPONSE
):
508 """Handle HTTP PUT requests"""
509 self
.dav__init(REQUEST
, RESPONSE
)
510 self
.dav__simpleifhandler(REQUEST
, RESPONSE
, refresh
=1)
511 type=REQUEST
.get_header('content-type', None)
513 file=REQUEST
['BODYFILE']
515 content_type
= self
._get
_content
_type
(file, self
.__name
__,
516 type or self
.content_type
)
517 self
.update_data(file, content_type
)
519 RESPONSE
.setStatus(204)
522 security
.declareProtected(View
, 'get_size')
524 """Get the size of a file or image.
526 Returns the size of the file or image.
532 self
.size
= size
= bf
.tell()
536 # deprecated; use get_size!
539 security
.declareProtected(View
, 'getContentType')
540 def getContentType(self
):
541 """Get the content type of a file or image.
543 Returns the content type (MIME type) of a file or image.
545 return self
.content_type
548 def __str__(self
): return str(self
.data
)
549 def __len__(self
): return 1
551 security
.declareProtected(ftp_access
, 'manage_FTPstat')
552 security
.declareProtected(ftp_access
, 'manage_FTPlist')
554 security
.declareProtected(ftp_access
, 'manage_FTPget')
555 def manage_FTPget(self
):
556 """Return body for ftp."""
557 RESPONSE
= self
.REQUEST
.RESPONSE
559 if self
.ZCacheable_isCachingEnabled():
560 result
= self
.ZCacheable_get(default
=None)
561 if result
is not None:
562 # We will always get None from RAMCacheManager but we will get
563 # something implementing the IStreamIterator interface
564 # from FileCacheManager.
565 # the content-length is required here by HTTPResponse, even
566 # though FTP doesn't use it.
567 RESPONSE
.setHeader('Content-Length', self
.size
)
573 RESPONSE
.setBase(None)
576 manage_addImageForm
=DTMLFile('dtml/imageAdd',globals(),
577 Kind
='Image',kind
='image')
578 def manage_addImage(self
, id, file, title
='', precondition
='', content_type
='',
581 Add a new Image object.
583 Creates a new Image object 'id' with the contents of 'file'.
588 content_type
=str(content_type
)
589 precondition
=str(precondition
)
591 id, title
= cookId(id, title
, file)
594 self
._setObject
(id, Image(id,title
,file,content_type
, precondition
))
596 newFile
= self
._getOb
(id)
597 notify(ObjectCreatedEvent(newFile
))
599 if REQUEST
is not None:
600 try: url
=self
.DestinationURL()
601 except: url
=REQUEST
['URL1']
602 REQUEST
.RESPONSE
.redirect('%s/manage_main' % url
)
606 def getImageInfo(file):
614 if (size
>= 10) and data
[:6] in ('GIF87a', 'GIF89a'):
615 # Check to see if content_type is correct
616 content_type
= 'image/gif'
617 w
, h
= struct
.unpack("<HH", data
[6:10])
621 # See PNG v1.2 spec (http://www.cdrom.com/pub/png/spec/)
622 # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
623 # and finally the 4-byte width, height
624 elif ((size
>= 24) and (data
[:8] == '\211PNG\r\n\032\n')
625 and (data
[12:16] == 'IHDR')):
626 content_type
= 'image/png'
627 w
, h
= struct
.unpack(">LL", data
[16:24])
631 # Maybe this is for an older PNG version.
632 elif (size
>= 16) and (data
[:8] == '\211PNG\r\n\032\n'):
633 # Check to see if we have the right content type
634 content_type
= 'image/png'
635 w
, h
= struct
.unpack(">LL", data
[8:16])
640 elif (size
>= 2) and (data
[:2] == '\377\330'):
641 content_type
= 'image/jpeg'
647 while (b
and ord(b
) != 0xDA):
648 while (ord(b
) != 0xFF): b
= jpeg
.read(1)
649 while (ord(b
) == 0xFF): b
= jpeg
.read(1)
650 if (ord(b
) >= 0xC0 and ord(b
) <= 0xC3):
652 h
, w
= struct
.unpack(">HH", jpeg
.read(4))
655 jpeg
.read(int(struct
.unpack(">H", jpeg
.read(2))[0])-2)
661 return content_type
, width
, height
665 """Image objects can be GIF, PNG or JPEG and have the same methods
666 as File objects. Images also have a string representation that
667 renders an HTML 'IMG' tag.
669 meta_type
='Blob Image'
671 security
= ClassSecurityInfo()
672 security
.declareObjectProtected(View
)
678 # FIXME: Redundant, already in base class
679 security
.declareProtected(change_images_and_files
, 'manage_edit')
680 security
.declareProtected(change_images_and_files
, 'manage_upload')
681 security
.declareProtected(change_images_and_files
, 'PUT')
682 security
.declareProtected(View
, 'index_html')
683 security
.declareProtected(View
, 'get_size')
684 security
.declareProtected(View
, 'getContentType')
685 security
.declareProtected(ftp_access
, 'manage_FTPstat')
686 security
.declareProtected(ftp_access
, 'manage_FTPlist')
687 security
.declareProtected(ftp_access
, 'manage_FTPget')
688 security
.declareProtected(delete_objects
, 'DELETE')
690 _properties
=({'id':'title', 'type': 'string'},
691 {'id':'alt', 'type':'string'},
692 {'id':'content_type', 'type':'string','mode':'w'},
693 {'id':'height', 'type':'string'},
694 {'id':'width', 'type':'string'},
698 ({'label':'Edit', 'action':'manage_main',
699 'help':('OFSP','Image_Edit.stx')},
700 {'label':'View', 'action':'view_image_or_file',
701 'help':('OFSP','Image_View.stx')},)
702 + PropertyManager
.manage_options
703 + RoleManager
.manage_options
704 + Item_w__name__
.manage_options
705 + Cacheable
.manage_options
708 manage_editForm
=DTMLFile('dtml/imageEdit',globals(),
709 Kind
='Image',kind
='image')
710 manage_editForm
._setName
('manage_editForm')
712 security
.declareProtected(View
, 'view_image_or_file')
713 view_image_or_file
=DTMLFile('dtml/imageView',globals())
715 security
.declareProtected(view_management_screens
, 'manage')
716 security
.declareProtected(view_management_screens
, 'manage_main')
717 manage
=manage_main
=manage_editForm
718 manage_uploadForm
=manage_editForm
720 security
.declarePrivate('update_data')
721 def update_data(self
, file, content_type
=None):
722 super(Image
, self
).update_data(file, content_type
)
723 self
.updateFormat(size
=self
.size
, content_type
=content_type
)
725 security
.declarePrivate('updateFormat')
726 def updateFormat(self
, size
=None, dimensions
=None, content_type
=None):
727 self
.updateSize(size
=size
)
729 if dimensions
is None or content_type
is None :
731 ct
, width
, height
= getImageInfo(bf
)
735 if width
>= 0 and height
>= 0:
739 # Now we should have the correct content type, or still None
740 if content_type
is not None: self
.content_type
= content_type
742 self
.width
, self
.height
= dimensions
743 self
.content_type
= content_type
748 security
.declareProtected(View
, 'tag')
749 def tag(self
, height
=None, width
=None, alt
=None,
750 scale
=0, xscale
=0, yscale
=0, css_class
=None, title
=None, **args
):
752 Generate an HTML IMG tag for this image, with customization.
753 Arguments to self.tag() can be any valid attributes of an IMG tag.
754 'src' will always be an absolute pathname, to prevent redundant
755 downloading of images. Defaults are applied intelligently for
756 'height', 'width', and 'alt'. If specified, the 'scale', 'xscale',
757 and 'yscale' keyword arguments will be used to automatically adjust
758 the output height and width values of the image tag.
760 Since 'class' is a Python reserved word, it cannot be passed in
761 directly in keyword arguments which is a problem if you are
762 trying to use 'tag()' to include a CSS class. The tag() method
763 will accept a 'css_class' argument that will be converted to
764 'class' in the output tag to work around this.
766 if height
is None: height
=self
.height
767 if width
is None: width
=self
.width
769 # Auto-scaling support
770 xdelta
= xscale
or scale
771 ydelta
= yscale
or scale
774 width
= str(int(round(int(width
) * xdelta
)))
775 if ydelta
and height
:
776 height
= str(int(round(int(height
) * ydelta
)))
778 result
='<img src="%s"' % (self
.absolute_url())
781 alt
=getattr(self
, 'alt', '')
782 result
= '%s alt="%s"' % (result
, escape(alt
, 1))
785 title
=getattr(self
, 'title', '')
786 result
= '%s title="%s"' % (result
, escape(title
, 1))
789 result
= '%s height="%s"' % (result
, height
)
792 result
= '%s width="%s"' % (result
, width
)
794 # Omitting 'border' attribute (Collector #1557)
795 # if not 'border' in [ x.lower() for x in args.keys()]:
796 # result = '%s border="0"' % result
798 if css_class
is not None:
799 result
= '%s class="%s"' % (result
, css_class
)
801 for key
in args
.keys():
802 value
= args
.get(key
)
804 result
= '%s %s="%s"' % (result
, key
, value
)
806 return '%s />' % result
809 def cookId(id, title
, file):
810 if not id and hasattr(file,'filename'):
811 filename
=file.filename
812 title
=title
or filename
813 id=filename
[max(filename
.rfind('/'),
814 filename
.rfind('\\'),