2123d75a8b7296f61d9f5f1619336935be174b42
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
)
490 notify(ObjectModifiedEvent(self
))
493 message
="Saved changes."
494 return self
.manage_main(self
,REQUEST
,manage_tabs_message
=message
)
496 def _get_content_type(self
, file, id, content_type
=None):
497 headers
=getattr(file, 'headers', None)
498 if headers
and headers
.has_key('content-type'):
499 content_type
=headers
['content-type']
501 name
= getattr(file, 'filename', self
.uploaded_filename
) or id
502 content_type
, enc
=guess_content_type(name
, '', content_type
)
505 security
.declareProtected(delete_objects
, 'DELETE')
507 security
.declareProtected(change_images_and_files
, 'PUT')
508 def PUT(self
, REQUEST
, RESPONSE
):
509 """Handle HTTP PUT requests"""
510 self
.dav__init(REQUEST
, RESPONSE
)
511 self
.dav__simpleifhandler(REQUEST
, RESPONSE
, refresh
=1)
512 type=REQUEST
.get_header('content-type', None)
514 file=REQUEST
['BODYFILE']
516 content_type
= self
._get
_content
_type
(file, self
.__name
__,
517 type or self
.content_type
)
518 self
.update_data(file, content_type
)
520 RESPONSE
.setStatus(204)
523 security
.declareProtected(View
, 'get_size')
525 """Get the size of a file or image.
527 Returns the size of the file or image.
533 self
.size
= size
= bf
.tell()
537 # deprecated; use get_size!
540 security
.declareProtected(View
, 'getContentType')
541 def getContentType(self
):
542 """Get the content type of a file or image.
544 Returns the content type (MIME type) of a file or image.
546 return self
.content_type
549 def __str__(self
): return str(self
.data
)
550 def __len__(self
): return 1
552 security
.declareProtected(ftp_access
, 'manage_FTPstat')
553 security
.declareProtected(ftp_access
, 'manage_FTPlist')
555 security
.declareProtected(ftp_access
, 'manage_FTPget')
556 def manage_FTPget(self
):
557 """Return body for ftp."""
558 RESPONSE
= self
.REQUEST
.RESPONSE
560 if self
.ZCacheable_isCachingEnabled():
561 result
= self
.ZCacheable_get(default
=None)
562 if result
is not None:
563 # We will always get None from RAMCacheManager but we will get
564 # something implementing the IStreamIterator interface
565 # from FileCacheManager.
566 # the content-length is required here by HTTPResponse, even
567 # though FTP doesn't use it.
568 RESPONSE
.setHeader('Content-Length', self
.size
)
574 RESPONSE
.setBase(None)
577 manage_addImageForm
=DTMLFile('dtml/imageAdd',globals(),
578 Kind
='Image',kind
='image')
579 def manage_addImage(self
, id, file, title
='', precondition
='', content_type
='',
582 Add a new Image object.
584 Creates a new Image object 'id' with the contents of 'file'.
589 content_type
=str(content_type
)
590 precondition
=str(precondition
)
592 id, title
= cookId(id, title
, file)
595 self
._setObject
(id, Image(id,title
,file,content_type
, precondition
))
597 newFile
= self
._getOb
(id)
598 notify(ObjectCreatedEvent(newFile
))
600 if REQUEST
is not None:
601 try: url
=self
.DestinationURL()
602 except: url
=REQUEST
['URL1']
603 REQUEST
.RESPONSE
.redirect('%s/manage_main' % url
)
607 def getImageInfo(file):
615 if (size
>= 10) and data
[:6] in ('GIF87a', 'GIF89a'):
616 # Check to see if content_type is correct
617 content_type
= 'image/gif'
618 w
, h
= struct
.unpack("<HH", data
[6:10])
622 # See PNG v1.2 spec (http://www.cdrom.com/pub/png/spec/)
623 # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
624 # and finally the 4-byte width, height
625 elif ((size
>= 24) and (data
[:8] == '\211PNG\r\n\032\n')
626 and (data
[12:16] == 'IHDR')):
627 content_type
= 'image/png'
628 w
, h
= struct
.unpack(">LL", data
[16:24])
632 # Maybe this is for an older PNG version.
633 elif (size
>= 16) and (data
[:8] == '\211PNG\r\n\032\n'):
634 # Check to see if we have the right content type
635 content_type
= 'image/png'
636 w
, h
= struct
.unpack(">LL", data
[8:16])
641 elif (size
>= 2) and (data
[:2] == '\377\330'):
642 content_type
= 'image/jpeg'
648 while (b
and ord(b
) != 0xDA):
649 while (ord(b
) != 0xFF): b
= jpeg
.read(1)
650 while (ord(b
) == 0xFF): b
= jpeg
.read(1)
651 if (ord(b
) >= 0xC0 and ord(b
) <= 0xC3):
653 h
, w
= struct
.unpack(">HH", jpeg
.read(4))
656 jpeg
.read(int(struct
.unpack(">H", jpeg
.read(2))[0])-2)
662 return content_type
, width
, height
666 """Image objects can be GIF, PNG or JPEG and have the same methods
667 as File objects. Images also have a string representation that
668 renders an HTML 'IMG' tag.
670 meta_type
='Blob Image'
672 security
= ClassSecurityInfo()
673 security
.declareObjectProtected(View
)
679 # FIXME: Redundant, already in base class
680 security
.declareProtected(change_images_and_files
, 'manage_edit')
681 security
.declareProtected(change_images_and_files
, 'manage_upload')
682 security
.declareProtected(change_images_and_files
, 'PUT')
683 security
.declareProtected(View
, 'index_html')
684 security
.declareProtected(View
, 'get_size')
685 security
.declareProtected(View
, 'getContentType')
686 security
.declareProtected(ftp_access
, 'manage_FTPstat')
687 security
.declareProtected(ftp_access
, 'manage_FTPlist')
688 security
.declareProtected(ftp_access
, 'manage_FTPget')
689 security
.declareProtected(delete_objects
, 'DELETE')
691 _properties
=({'id':'title', 'type': 'string'},
692 {'id':'alt', 'type':'string'},
693 {'id':'content_type', 'type':'string','mode':'w'},
694 {'id':'height', 'type':'string'},
695 {'id':'width', 'type':'string'},
699 ({'label':'Edit', 'action':'manage_main',
700 'help':('OFSP','Image_Edit.stx')},
701 {'label':'View', 'action':'view_image_or_file',
702 'help':('OFSP','Image_View.stx')},)
703 + PropertyManager
.manage_options
704 + RoleManager
.manage_options
705 + Item_w__name__
.manage_options
706 + Cacheable
.manage_options
709 manage_editForm
=DTMLFile('dtml/imageEdit',globals(),
710 Kind
='Image',kind
='image')
711 manage_editForm
._setName
('manage_editForm')
713 security
.declareProtected(View
, 'view_image_or_file')
714 view_image_or_file
=DTMLFile('dtml/imageView',globals())
716 security
.declareProtected(view_management_screens
, 'manage')
717 security
.declareProtected(view_management_screens
, 'manage_main')
718 manage
=manage_main
=manage_editForm
719 manage_uploadForm
=manage_editForm
721 security
.declarePrivate('update_data')
722 def update_data(self
, file, content_type
=None):
723 super(Image
, self
).update_data(file, content_type
)
724 self
.updateFormat(size
=self
.size
, content_type
=content_type
)
726 security
.declarePrivate('updateFormat')
727 def updateFormat(self
, size
=None, dimensions
=None, content_type
=None):
728 self
.updateSize(size
=size
)
730 if dimensions
is None or content_type
is None :
732 ct
, width
, height
= getImageInfo(bf
)
736 if width
>= 0 and height
>= 0:
740 # Now we should have the correct content type, or still None
741 if content_type
is not None: self
.content_type
= content_type
743 self
.width
, self
.height
= dimensions
744 self
.content_type
= content_type
749 security
.declareProtected(View
, 'tag')
750 def tag(self
, height
=None, width
=None, alt
=None,
751 scale
=0, xscale
=0, yscale
=0, css_class
=None, title
=None, **args
):
753 Generate an HTML IMG tag for this image, with customization.
754 Arguments to self.tag() can be any valid attributes of an IMG tag.
755 'src' will always be an absolute pathname, to prevent redundant
756 downloading of images. Defaults are applied intelligently for
757 'height', 'width', and 'alt'. If specified, the 'scale', 'xscale',
758 and 'yscale' keyword arguments will be used to automatically adjust
759 the output height and width values of the image tag.
761 Since 'class' is a Python reserved word, it cannot be passed in
762 directly in keyword arguments which is a problem if you are
763 trying to use 'tag()' to include a CSS class. The tag() method
764 will accept a 'css_class' argument that will be converted to
765 'class' in the output tag to work around this.
767 if height
is None: height
=self
.height
768 if width
is None: width
=self
.width
770 # Auto-scaling support
771 xdelta
= xscale
or scale
772 ydelta
= yscale
or scale
775 width
= str(int(round(int(width
) * xdelta
)))
776 if ydelta
and height
:
777 height
= str(int(round(int(height
) * ydelta
)))
779 result
='<img src="%s"' % (self
.absolute_url())
782 alt
=getattr(self
, 'alt', '')
783 result
= '%s alt="%s"' % (result
, escape(alt
, 1))
786 title
=getattr(self
, 'title', '')
787 result
= '%s title="%s"' % (result
, escape(title
, 1))
790 result
= '%s height="%s"' % (result
, height
)
793 result
= '%s width="%s"' % (result
, width
)
795 # Omitting 'border' attribute (Collector #1557)
796 # if not 'border' in [ x.lower() for x in args.keys()]:
797 # result = '%s border="0"' % result
799 if css_class
is not None:
800 result
= '%s class="%s"' % (result
, css_class
)
802 for key
in args
.keys():
803 value
= args
.get(key
)
805 result
= '%s %s="%s"' % (result
, key
, value
)
807 return '%s />' % result
810 def cookId(id, title
, file):
811 if not id and hasattr(file,'filename'):
812 filename
=file.filename
813 title
=title
or filename
814 id=filename
[max(filename
.rfind('/'),
815 filename
.rfind('\\'),