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
23 from warnings
import warn
25 from AccessControl
.Permissions
import change_images_and_files
26 from AccessControl
.Permissions
import view_management_screens
27 from AccessControl
.Permissions
import view
as View
28 from AccessControl
.Permissions
import ftp_access
29 from AccessControl
.Permissions
import delete_objects
30 from AccessControl
.Role
import RoleManager
31 from AccessControl
.SecurityInfo
import ClassSecurityInfo
32 from Acquisition
import Implicit
33 from App
.class_init
import InitializeClass
34 from App
.special_dtml
import DTMLFile
35 from DateTime
.DateTime
import DateTime
36 from Persistence
import Persistent
37 from webdav
.common
import rfc1123_date
38 from webdav
.interfaces
import IWriteLock
39 from webdav
.Lockable
import ResourceLockedError
40 from ZPublisher
import HTTPRangeSupport
41 from ZPublisher
.HTTPRequest
import FileUpload
42 from ZPublisher
.Iterators
import filestream_iterator
43 from zExceptions
import Redirect
44 from zope
.contenttype
import guess_content_type
45 from zope
.interface
import implementedBy
46 from zope
.interface
import implements
48 from OFS
.Cache
import Cacheable
49 from OFS
.PropertyManager
import PropertyManager
50 from OFS
.SimpleItem
import Item_w__name__
52 from zope
.event
import notify
53 from zope
.lifecycleevent
import ObjectModifiedEvent
54 from zope
.lifecycleevent
import ObjectCreatedEvent
56 from ZODB
.blob
import Blob
61 manage_addFileForm
= DTMLFile('dtml/imageAdd',
66 def manage_addFile(self
, id, file='', title
='', precondition
='',
67 content_type
='', REQUEST
=None):
68 """Add a new File object.
70 Creates a new File object 'id' with the contents of 'file'"""
74 content_type
= str(content_type
)
75 precondition
= str(precondition
)
77 id, title
= cookId(id, title
, file)
80 self
._setObject
(id, File(id,title
,file,content_type
, precondition
))
82 newFile
= self
._getOb
(id)
83 notify(ObjectCreatedEvent(newFile
))
85 if REQUEST
is not None:
86 REQUEST
['RESPONSE'].redirect(self
.absolute_url()+'/manage_main')
89 class File(Persistent
, Implicit
, PropertyManager
,
90 RoleManager
, Item_w__name__
, Cacheable
):
91 """A File object is a content object for arbitrary files."""
93 implements(implementedBy(Persistent
),
94 implementedBy(Implicit
),
95 implementedBy(PropertyManager
),
96 implementedBy(RoleManager
),
97 implementedBy(Item_w__name__
),
98 implementedBy(Cacheable
),
100 HTTPRangeSupport
.HTTPRangeInterface
,
102 meta_type
='Blob File'
104 security
= ClassSecurityInfo()
105 security
.declareObjectProtected(View
)
110 manage_editForm
=DTMLFile('dtml/fileEdit',globals(),
111 Kind
='File',kind
='file')
112 manage_editForm
._setName
('manage_editForm')
114 security
.declareProtected(view_management_screens
, 'manage')
115 security
.declareProtected(view_management_screens
, 'manage_main')
116 manage
=manage_main
=manage_editForm
117 manage_uploadForm
=manage_editForm
121 {'label':'Edit', 'action':'manage_main',
122 'help':('OFSP','File_Edit.stx')},
123 {'label':'View', 'action':'',
124 'help':('OFSP','File_View.stx')},
126 + PropertyManager
.manage_options
127 + RoleManager
.manage_options
128 + Item_w__name__
.manage_options
129 + Cacheable
.manage_options
132 _properties
=({'id':'title', 'type': 'string'},
133 {'id':'content_type', 'type':'string'},
136 def __init__(self
, id, title
, file, content_type
='', precondition
=''):
139 self
.precondition
=precondition
140 self
.uploaded_filename
= cookId('', '', file)[0]
143 content_type
=self
._get
_content
_type
(file, id, content_type
)
144 self
.update_data(file, content_type
)
146 security
.declarePrivate('save')
147 def save(self
, file):
148 bf
= self
.bdata
.open('w')
149 bf
.write(file.read())
150 self
.size
= bf
.tell()
153 security
.declarePrivate('open')
154 def open(self
, mode
='r'):
155 bf
= self
.bdata
.open(mode
)
158 security
.declarePrivate('updateSize')
159 def updateSize(self
, size
=None):
163 self
.size
= bf
.tell()
168 def _getLegacyData(self
) :
169 warn("Accessing 'data' attribute may be inefficient with "
170 "this blob based file. You should refactor your product "
171 "by accessing data like: "
172 "f = self.open('r') "
174 DeprecationWarning, stacklevel
=2)
180 def _setLegacyData(self
, data
) :
181 warn("Accessing 'data' attribute may be inefficient with "
182 "this blob based file. You should refactor your product "
183 "by accessing data like: "
184 "f = self.save(data)",
185 DeprecationWarning, stacklevel
=2)
186 if isinstance(data
, str) :
193 data
= property(_getLegacyData
, _setLegacyData
,
194 "Data Legacy attribute to ensure compatibility "
195 "with derived classes that access data by this way.")
200 def _if_modified_since_request_handler(self
, REQUEST
, RESPONSE
):
201 # HTTP If-Modified-Since header handling: return True if
202 # we can handle this request by returning a 304 response
203 header
=REQUEST
.get_header('If-Modified-Since', None)
204 if header
is not None:
205 header
=header
.split( ';')[0]
206 # Some proxies seem to send invalid date strings for this
207 # header. If the date string is not valid, we ignore it
208 # rather than raise an error to be generally consistent
209 # with common servers such as Apache (which can usually
210 # understand the screwy date string as a lucky side effect
211 # of the way they parse it).
212 # This happens to be what RFC2616 tells us to do in the face of an
214 try: mod_since
=long(DateTime(header
).timeTime())
215 except: mod_since
=None
216 if mod_since
is not None:
218 last_mod
= long(self
._p
_mtime
)
221 if last_mod
> 0 and last_mod
<= mod_since
:
222 RESPONSE
.setHeader('Last-Modified',
223 rfc1123_date(self
._p
_mtime
))
224 RESPONSE
.setHeader('Content-Type', self
.content_type
)
225 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
226 RESPONSE
.setStatus(304)
229 def _range_request_handler(self
, REQUEST
, RESPONSE
):
230 # HTTP Range header handling: return True if we've served a range
231 # chunk out of our data.
232 range = REQUEST
.get_header('Range', None)
233 request_range
= REQUEST
.get_header('Request-Range', None)
234 if request_range
is not None:
235 # Netscape 2 through 4 and MSIE 3 implement a draft version
236 # Later on, we need to serve a different mime-type as well.
237 range = request_range
238 if_range
= REQUEST
.get_header('If-Range', None)
239 if range is not None:
240 ranges
= HTTPRangeSupport
.parseRange(range)
242 if if_range
is not None:
243 # Only send ranges if the data isn't modified, otherwise send
244 # the whole object. Support both ETags and Last-Modified dates!
245 if len(if_range
) > 1 and if_range
[:2] == 'ts':
247 if if_range
!= self
.http__etag():
248 # Modified, so send a normal response. We delete
249 # the ranges, which causes us to skip to the 200
254 date
= if_range
.split( ';')[0]
255 try: mod_since
=long(DateTime(date
).timeTime())
256 except: mod_since
=None
257 if mod_since
is not None:
259 last_mod
= long(self
._p
_mtime
)
262 if last_mod
> mod_since
:
263 # Modified, so send a normal response. We delete
264 # the ranges, which causes us to skip to the 200
269 # Search for satisfiable ranges.
271 for start
, end
in ranges
:
272 if start
< self
.size
:
277 RESPONSE
.setHeader('Content-Range',
278 'bytes */%d' % self
.size
)
279 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
280 RESPONSE
.setHeader('Last-Modified',
281 rfc1123_date(self
._p
_mtime
))
282 RESPONSE
.setHeader('Content-Type', self
.content_type
)
283 RESPONSE
.setHeader('Content-Length', self
.size
)
284 RESPONSE
.setStatus(416)
287 ranges
= HTTPRangeSupport
.expandRanges(ranges
, self
.size
)
290 # Easy case, set extra header and return partial set.
291 start
, end
= ranges
[0]
294 RESPONSE
.setHeader('Last-Modified',
295 rfc1123_date(self
._p
_mtime
))
296 RESPONSE
.setHeader('Content-Type', self
.content_type
)
297 RESPONSE
.setHeader('Content-Length', size
)
298 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
299 RESPONSE
.setHeader('Content-Range',
300 'bytes %d-%d/%d' % (start
, end
- 1, self
.size
))
301 RESPONSE
.setStatus(206) # Partial content
305 RESPONSE
.write(bf
.read(size
))
310 boundary
= choose_boundary()
312 # Calculate the content length
313 size
= (8 + len(boundary
) + # End marker length
314 len(ranges
) * ( # Constant lenght per set
315 49 + len(boundary
) + len(self
.content_type
) +
316 len('%d' % self
.size
)))
317 for start
, end
in ranges
:
318 # Variable length per set
319 size
= (size
+ len('%d%d' % (start
, end
- 1)) +
323 # Some clients implement an earlier draft of the spec, they
324 # will only accept x-byteranges.
325 draftprefix
= (request_range
is not None) and 'x-' or ''
327 RESPONSE
.setHeader('Content-Length', size
)
328 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
329 RESPONSE
.setHeader('Last-Modified',
330 rfc1123_date(self
._p
_mtime
))
331 RESPONSE
.setHeader('Content-Type',
332 'multipart/%sbyteranges; boundary=%s' % (
333 draftprefix
, boundary
))
334 RESPONSE
.setStatus(206) # Partial content
339 # # The Pdata map allows us to jump into the Pdata chain
340 # # arbitrarily during out-of-order range searching.
342 # pdata_map[0] = data
344 for start
, end
in ranges
:
345 RESPONSE
.write('\r\n--%s\r\n' % boundary
)
346 RESPONSE
.write('Content-Type: %s\r\n' %
349 'Content-Range: bytes %d-%d/%d\r\n\r\n' % (
350 start
, end
- 1, self
.size
))
355 RESPONSE
.write(bf
.read(size
))
359 RESPONSE
.write('\r\n--%s--\r\n' % boundary
)
362 security
.declareProtected(View
, 'index_html')
363 def index_html(self
, REQUEST
, RESPONSE
):
365 The default view of the contents of a File or Image.
367 Returns the contents of the file or image. Also, sets the
368 Content-Type HTTP header to the objects content type.
371 if self
._if
_modified
_since
_request
_handler
(REQUEST
, RESPONSE
):
372 # we were able to handle this by returning a 304
373 # unfortunately, because the HTTP cache manager uses the cache
374 # API, and because 304 responses are required to carry the Expires
375 # header for HTTP/1.1, we need to call ZCacheable_set here.
376 # This is nonsensical for caches other than the HTTP cache manager
378 self
.ZCacheable_set(None)
381 if self
.precondition
and hasattr(self
, str(self
.precondition
)):
382 # Grab whatever precondition was defined and then
383 # execute it. The precondition will raise an exception
384 # if something violates its terms.
385 c
=getattr(self
, str(self
.precondition
))
386 if hasattr(c
,'isDocTemp') and c
.isDocTemp
:
387 c(REQUEST
['PARENTS'][1],REQUEST
)
391 if self
._range
_request
_handler
(REQUEST
, RESPONSE
):
392 # we served a chunk of content in response to a range request.
395 RESPONSE
.setHeader('Last-Modified', rfc1123_date(self
._p
_mtime
))
396 RESPONSE
.setHeader('Content-Type', self
.content_type
)
397 RESPONSE
.setHeader('Content-Length', self
.size
)
398 RESPONSE
.setHeader('Accept-Ranges', 'bytes')
400 if self
.ZCacheable_isCachingEnabled():
401 result
= self
.ZCacheable_get(default
=None)
402 if result
is not None:
403 # We will always get None from RAMCacheManager and HTTP
404 # Accelerated Cache Manager but we will get
405 # something implementing the IStreamIterator interface
406 # from a "FileCacheManager"
409 self
.ZCacheable_set(None)
412 chunk
= bf
.read(CHUNK_SIZE
)
414 RESPONSE
.write(chunk
)
415 chunk
= bf
.read(CHUNK_SIZE
)
419 security
.declareProtected(View
, 'view_image_or_file')
420 def view_image_or_file(self
, URL1
):
422 The default view of the contents of the File or Image.
426 security
.declareProtected(View
, 'PrincipiaSearchSource')
427 def PrincipiaSearchSource(self
):
428 """ Allow file objects to be searched.
430 if self
.content_type
.startswith('text/'):
437 security
.declarePrivate('update_data')
438 def update_data(self
, file, content_type
=None):
439 if isinstance(file, unicode):
440 raise TypeError('Data can only be str or file-like. '
441 'Unicode objects are expressly forbidden.')
442 elif isinstance(file, str) :
448 if content_type
is not None: self
.content_type
=content_type
450 self
.ZCacheable_invalidate()
451 self
.ZCacheable_set(None)
452 self
.http__refreshEtag()
454 security
.declareProtected(change_images_and_files
, 'manage_edit')
455 def manage_edit(self
, title
, content_type
, precondition
='',
456 filedata
=None, REQUEST
=None):
458 Changes the title and content type attributes of the File or Image.
460 if self
.wl_isLocked():
461 raise ResourceLockedError
, "File is locked via WebDAV"
463 self
.title
=str(title
)
464 self
.content_type
=str(content_type
)
465 if precondition
: self
.precondition
=str(precondition
)
466 elif self
.precondition
: del self
.precondition
467 if filedata
is not None:
468 self
.update_data(filedata
, content_type
)
470 self
.ZCacheable_invalidate()
472 notify(ObjectModifiedEvent(self
))
475 message
="Saved changes."
476 return self
.manage_main(self
,REQUEST
,manage_tabs_message
=message
)
478 security
.declareProtected(change_images_and_files
, 'manage_upload')
479 def manage_upload(self
,file='',REQUEST
=None):
481 Replaces the current contents of the File or Image object with file.
483 The file or images contents are replaced with the contents of 'file'.
485 if self
.wl_isLocked():
486 raise ResourceLockedError
, "File is locked via WebDAV"
488 content_type
=self
._get
_content
_type
(file, self
.__name
__,
489 'application/octet-stream')
490 self
.update_data(file, content_type
)
491 notify(ObjectModifiedEvent(self
))
494 message
="Saved changes."
495 return self
.manage_main(self
,REQUEST
,manage_tabs_message
=message
)
497 def _get_content_type(self
, file, id, content_type
=None):
498 headers
=getattr(file, 'headers', None)
499 if headers
and headers
.has_key('content-type'):
500 content_type
=headers
['content-type']
502 name
= getattr(file, 'filename', self
.uploaded_filename
) or id
503 content_type
, enc
=guess_content_type(name
, '', content_type
)
506 security
.declareProtected(delete_objects
, 'DELETE')
508 security
.declareProtected(change_images_and_files
, 'PUT')
509 def PUT(self
, REQUEST
, RESPONSE
):
510 """Handle HTTP PUT requests"""
511 self
.dav__init(REQUEST
, RESPONSE
)
512 self
.dav__simpleifhandler(REQUEST
, RESPONSE
, refresh
=1)
513 type=REQUEST
.get_header('content-type', None)
515 file=REQUEST
['BODYFILE']
517 content_type
= self
._get
_content
_type
(file, self
.__name
__,
518 type or self
.content_type
)
519 self
.update_data(file, content_type
)
521 RESPONSE
.setStatus(204)
524 security
.declareProtected(View
, 'get_size')
526 """Get the size of a file or image.
528 Returns the size of the file or image.
534 self
.size
= size
= bf
.tell()
538 # deprecated; use get_size!
541 security
.declareProtected(View
, 'getContentType')
542 def getContentType(self
):
543 """Get the content type of a file or image.
545 Returns the content type (MIME type) of a file or image.
547 return self
.content_type
550 def __str__(self
): return str(self
.data
)
551 def __len__(self
): return 1
553 security
.declareProtected(ftp_access
, 'manage_FTPstat')
554 security
.declareProtected(ftp_access
, 'manage_FTPlist')
556 security
.declareProtected(ftp_access
, 'manage_FTPget')
557 def manage_FTPget(self
):
558 """Return body for ftp."""
559 RESPONSE
= self
.REQUEST
.RESPONSE
561 if self
.ZCacheable_isCachingEnabled():
562 result
= self
.ZCacheable_get(default
=None)
563 if result
is not None:
564 # We will always get None from RAMCacheManager but we will get
565 # something implementing the IStreamIterator interface
566 # from FileCacheManager.
567 # the content-length is required here by HTTPResponse, even
568 # though FTP doesn't use it.
569 RESPONSE
.setHeader('Content-Length', self
.size
)
575 RESPONSE
.setBase(None)
578 manage_addImageForm
=DTMLFile('dtml/imageAdd',globals(),
579 Kind
='Image',kind
='image')
580 def manage_addImage(self
, id, file, title
='', precondition
='', content_type
='',
583 Add a new Image object.
585 Creates a new Image object 'id' with the contents of 'file'.
590 content_type
=str(content_type
)
591 precondition
=str(precondition
)
593 id, title
= cookId(id, title
, file)
596 self
._setObject
(id, Image(id,title
,file,content_type
, precondition
))
598 newFile
= self
._getOb
(id)
599 notify(ObjectCreatedEvent(newFile
))
601 if REQUEST
is not None:
602 try: url
=self
.DestinationURL()
603 except: url
=REQUEST
['URL1']
604 REQUEST
.RESPONSE
.redirect('%s/manage_main' % url
)
608 def getImageInfo(file):
616 if (size
>= 10) and data
[:6] in ('GIF87a', 'GIF89a'):
617 # Check to see if content_type is correct
618 content_type
= 'image/gif'
619 w
, h
= struct
.unpack("<HH", data
[6:10])
623 # See PNG v1.2 spec (http://www.cdrom.com/pub/png/spec/)
624 # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
625 # and finally the 4-byte width, height
626 elif ((size
>= 24) and (data
[:8] == '\211PNG\r\n\032\n')
627 and (data
[12:16] == 'IHDR')):
628 content_type
= 'image/png'
629 w
, h
= struct
.unpack(">LL", data
[16:24])
633 # Maybe this is for an older PNG version.
634 elif (size
>= 16) and (data
[:8] == '\211PNG\r\n\032\n'):
635 # Check to see if we have the right content type
636 content_type
= 'image/png'
637 w
, h
= struct
.unpack(">LL", data
[8:16])
642 elif (size
>= 2) and (data
[:2] == '\377\330'):
643 content_type
= 'image/jpeg'
649 while (b
and ord(b
) != 0xDA):
650 while (ord(b
) != 0xFF): b
= jpeg
.read(1)
651 while (ord(b
) == 0xFF): b
= jpeg
.read(1)
652 if (ord(b
) >= 0xC0 and ord(b
) <= 0xC3):
654 h
, w
= struct
.unpack(">HH", jpeg
.read(4))
657 jpeg
.read(int(struct
.unpack(">H", jpeg
.read(2))[0])-2)
663 return content_type
, width
, height
667 """Image objects can be GIF, PNG or JPEG and have the same methods
668 as File objects. Images also have a string representation that
669 renders an HTML 'IMG' tag.
671 meta_type
='Blob Image'
673 security
= ClassSecurityInfo()
674 security
.declareObjectProtected(View
)
680 # FIXME: Redundant, already in base class
681 security
.declareProtected(change_images_and_files
, 'manage_edit')
682 security
.declareProtected(change_images_and_files
, 'manage_upload')
683 security
.declareProtected(change_images_and_files
, 'PUT')
684 security
.declareProtected(View
, 'index_html')
685 security
.declareProtected(View
, 'get_size')
686 security
.declareProtected(View
, 'getContentType')
687 security
.declareProtected(ftp_access
, 'manage_FTPstat')
688 security
.declareProtected(ftp_access
, 'manage_FTPlist')
689 security
.declareProtected(ftp_access
, 'manage_FTPget')
690 security
.declareProtected(delete_objects
, 'DELETE')
692 _properties
=({'id':'title', 'type': 'string'},
693 {'id':'alt', 'type':'string'},
694 {'id':'content_type', 'type':'string','mode':'w'},
695 {'id':'height', 'type':'string'},
696 {'id':'width', 'type':'string'},
700 ({'label':'Edit', 'action':'manage_main',
701 'help':('OFSP','Image_Edit.stx')},
702 {'label':'View', 'action':'view_image_or_file',
703 'help':('OFSP','Image_View.stx')},)
704 + PropertyManager
.manage_options
705 + RoleManager
.manage_options
706 + Item_w__name__
.manage_options
707 + Cacheable
.manage_options
710 manage_editForm
=DTMLFile('dtml/imageEdit',globals(),
711 Kind
='Image',kind
='image')
712 manage_editForm
._setName
('manage_editForm')
714 security
.declareProtected(View
, 'view_image_or_file')
715 view_image_or_file
=DTMLFile('dtml/imageView',globals())
717 security
.declareProtected(view_management_screens
, 'manage')
718 security
.declareProtected(view_management_screens
, 'manage_main')
719 manage
=manage_main
=manage_editForm
720 manage_uploadForm
=manage_editForm
722 security
.declarePrivate('update_data')
723 def update_data(self
, file, content_type
=None):
724 super(Image
, self
).update_data(file, content_type
)
725 self
.updateFormat(size
=self
.size
, content_type
=content_type
)
727 security
.declarePrivate('updateFormat')
728 def updateFormat(self
, size
=None, dimensions
=None, content_type
=None):
729 self
.updateSize(size
=size
)
731 if dimensions
is None or content_type
is None :
733 ct
, width
, height
= getImageInfo(bf
)
737 if width
>= 0 and height
>= 0:
741 # Now we should have the correct content type, or still None
742 if content_type
is not None: self
.content_type
= content_type
744 self
.width
, self
.height
= dimensions
745 self
.content_type
= content_type
750 security
.declareProtected(View
, 'tag')
751 def tag(self
, height
=None, width
=None, alt
=None,
752 scale
=0, xscale
=0, yscale
=0, css_class
=None, title
=None, **args
):
754 Generate an HTML IMG tag for this image, with customization.
755 Arguments to self.tag() can be any valid attributes of an IMG tag.
756 'src' will always be an absolute pathname, to prevent redundant
757 downloading of images. Defaults are applied intelligently for
758 'height', 'width', and 'alt'. If specified, the 'scale', 'xscale',
759 and 'yscale' keyword arguments will be used to automatically adjust
760 the output height and width values of the image tag.
762 Since 'class' is a Python reserved word, it cannot be passed in
763 directly in keyword arguments which is a problem if you are
764 trying to use 'tag()' to include a CSS class. The tag() method
765 will accept a 'css_class' argument that will be converted to
766 'class' in the output tag to work around this.
768 if height
is None: height
=self
.height
769 if width
is None: width
=self
.width
771 # Auto-scaling support
772 xdelta
= xscale
or scale
773 ydelta
= yscale
or scale
776 width
= str(int(round(int(width
) * xdelta
)))
777 if ydelta
and height
:
778 height
= str(int(round(int(height
) * ydelta
)))
780 result
='<img src="%s"' % (self
.absolute_url())
783 alt
=getattr(self
, 'alt', '')
784 result
= '%s alt="%s"' % (result
, escape(alt
, 1))
787 title
=getattr(self
, 'title', '')
788 result
= '%s title="%s"' % (result
, escape(title
, 1))
791 result
= '%s height="%s"' % (result
, height
)
794 result
= '%s width="%s"' % (result
, width
)
796 # Omitting 'border' attribute (Collector #1557)
797 # if not 'border' in [ x.lower() for x in args.keys()]:
798 # result = '%s border="0"' % result
800 if css_class
is not None:
801 result
= '%s class="%s"' % (result
, css_class
)
803 for key
in args
.keys():
804 value
= args
.get(key
)
806 result
= '%s %s="%s"' % (result
, key
, value
)
808 return '%s />' % result
811 def cookId(id, title
, file):
812 if not id and hasattr(file,'filename'):
813 filename
=file.filename
814 title
=title
or filename
815 id=filename
[max(filename
.rfind('/'),
816 filename
.rfind('\\'),