• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / Zope / 5986569611

26 Aug 2023 07:20PM UTC coverage: 82.139% (-0.09%) from 82.233%
5986569611

Pull #1146

github

drfho
fix linting (2)
Pull Request #1146: Added Image Info for SVG files

4331 of 6934 branches covered (0.0%)

Branch coverage included in aggregate %.

32 of 32 new or added lines in 1 file covered. (100.0%)

27299 of 31574 relevant lines covered (86.46%)

0.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.91
/src/OFS/Image.py
1
##############################################################################
2
#
3
# Copyright (c) 2002 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE
11
#
12
##############################################################################
13
"""Image object
1✔
14
"""
15

16
import html
1✔
17
import struct
1✔
18
from email.generator import _make_boundary
1✔
19
from io import BytesIO
1✔
20
from xml.dom import minidom
1✔
21

22
import ZPublisher.HTTPRequest
1✔
23
from AccessControl.class_init import InitializeClass
1✔
24
from AccessControl.Permissions import change_images_and_files  # NOQA
1✔
25
from AccessControl.Permissions import view as View
1✔
26
from AccessControl.Permissions import view_management_screens
1✔
27
from AccessControl.Permissions import webdav_access
1✔
28
from AccessControl.SecurityInfo import ClassSecurityInfo
1✔
29
from Acquisition import Implicit
1✔
30
from App.special_dtml import DTMLFile
1✔
31
from DateTime.DateTime import DateTime
1✔
32
from OFS.Cache import Cacheable
1✔
33
from OFS.interfaces import IWriteLock
1✔
34
from OFS.PropertyManager import PropertyManager
1✔
35
from OFS.role import RoleManager
1✔
36
from OFS.SimpleItem import Item_w__name__
1✔
37
from OFS.SimpleItem import PathReprProvider
1✔
38
from Persistence import Persistent
1✔
39
from zExceptions import Redirect
1✔
40
from zExceptions import ResourceLockedError
1✔
41
from zope.contenttype import guess_content_type
1✔
42
from zope.datetime import rfc1123_date
1✔
43
from zope.event import notify
1✔
44
from zope.interface import implementer
1✔
45
from zope.lifecycleevent import ObjectCreatedEvent
1✔
46
from zope.lifecycleevent import ObjectModifiedEvent
1✔
47
from ZPublisher import HTTPRangeSupport
1✔
48
from ZPublisher.HTTPRequest import FileUpload
1✔
49

50

51
manage_addFileForm = DTMLFile(
1✔
52
    'dtml/imageAdd',
53
    globals(),
54
    Kind='File',
55
    kind='file',
56
)
57

58

59
def manage_addFile(
1✔
60
    self,
61
    id,
62
    file=b'',
63
    title='',
64
    precondition='',
65
    content_type='',
66
    REQUEST=None
67
):
68
    """Add a new File object.
69

70
    Creates a new File object 'id' with the contents of 'file'"""
71

72
    id = str(id)
1✔
73
    title = str(title)
1✔
74
    content_type = str(content_type)
1✔
75
    precondition = str(precondition)
1✔
76

77
    id, title = cookId(id, title, file)
1✔
78

79
    self = self.this()
1✔
80

81
    # First, we create the file without data:
82
    self._setObject(id, File(id, title, b'', content_type, precondition))
1✔
83

84
    newFile = self._getOb(id)
1✔
85

86
    # Now we "upload" the data.  By doing this in two steps, we
87
    # can use a database trick to make the upload more efficient.
88
    if file:
1✔
89
        newFile.manage_upload(file)
1✔
90
    if content_type:
1✔
91
        newFile.content_type = content_type
1✔
92

93
    notify(ObjectCreatedEvent(newFile))
1✔
94

95
    if REQUEST is not None:
1!
96
        REQUEST.RESPONSE.redirect(self.absolute_url() + '/manage_main')
×
97

98

99
@implementer(IWriteLock, HTTPRangeSupport.HTTPRangeInterface)
1✔
100
class File(
1✔
101
    PathReprProvider,
102
    Persistent,
103
    Implicit,
104
    PropertyManager,
105
    RoleManager,
106
    Item_w__name__,
107
    Cacheable
108
):
109
    """A File object is a content object for arbitrary files."""
110

111
    meta_type = 'File'
1✔
112
    zmi_icon = 'far fa-file-archive'
1✔
113

114
    security = ClassSecurityInfo()
1✔
115
    security.declareObjectProtected(View)
1✔
116

117
    precondition = ''
1✔
118
    size = None
1✔
119

120
    manage_editForm = DTMLFile('dtml/fileEdit', globals(),
1✔
121
                               Kind='File', kind='file')
122
    manage_editForm._setName('manage_editForm')
1✔
123

124
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
125
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
126
    manage = manage_main = manage_editForm
1✔
127
    manage_uploadForm = manage_editForm
1✔
128

129
    manage_options = (({'label': 'Edit', 'action': 'manage_main'},
1✔
130
                       {'label': 'View', 'action': ''})
131
                      + PropertyManager.manage_options
132
                      + RoleManager.manage_options
133
                      + Item_w__name__.manage_options
134
                      + Cacheable.manage_options)
135

136
    _properties = (
1✔
137
        {'id': 'title', 'type': 'string'},
138
        {'id': 'content_type', 'type': 'string'},
139
    )
140

141
    def __init__(self, id, title, file, content_type='', precondition=''):
1✔
142
        self.__name__ = id
1✔
143
        self.title = title
1✔
144
        self.precondition = precondition
1✔
145

146
        data, size = self._read_data(file)
1✔
147
        content_type = self._get_content_type(file, data, id, content_type)
1✔
148
        self.update_data(data, content_type, size)
1✔
149

150
    def _if_modified_since_request_handler(self, REQUEST, RESPONSE):
1✔
151
        # HTTP If-Modified-Since header handling: return True if
152
        # we can handle this request by returning a 304 response
153
        header = REQUEST.get_header('If-Modified-Since', None)
1✔
154
        if header is not None:
1✔
155
            header = header.split(';')[0]
1✔
156
            # Some proxies seem to send invalid date strings for this
157
            # header. If the date string is not valid, we ignore it
158
            # rather than raise an error to be generally consistent
159
            # with common servers such as Apache (which can usually
160
            # understand the screwy date string as a lucky side effect
161
            # of the way they parse it).
162
            # This happens to be what RFC2616 tells us to do in the face of an
163
            # invalid date.
164
            try:
1✔
165
                mod_since = int(DateTime(header).timeTime())
1✔
166
            except Exception:
×
167
                mod_since = None
×
168
            if mod_since is not None:
1!
169
                if self._p_mtime:
1!
170
                    last_mod = int(self._p_mtime)
1✔
171
                else:
172
                    last_mod = 0
×
173
                if last_mod > 0 and last_mod <= mod_since:
1✔
174
                    RESPONSE.setHeader(
1✔
175
                        'Last-Modified', rfc1123_date(self._p_mtime)
176
                    )
177
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
178
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
179
                    RESPONSE.setStatus(304)
1✔
180
                    return True
1✔
181

182
    def _range_request_handler(self, REQUEST, RESPONSE):
1✔
183
        # HTTP Range header handling: return True if we've served a range
184
        # chunk out of our data.
185
        range = REQUEST.get_header('Range', None)
1✔
186
        request_range = REQUEST.get_header('Request-Range', None)
1✔
187
        if request_range is not None:
1✔
188
            # Netscape 2 through 4 and MSIE 3 implement a draft version
189
            # Later on, we need to serve a different mime-type as well.
190
            range = request_range
1✔
191
        if_range = REQUEST.get_header('If-Range', None)
1✔
192
        if range is not None:
1✔
193
            ranges = HTTPRangeSupport.parseRange(range)
1✔
194

195
            if if_range is not None:
1✔
196
                # Only send ranges if the data isn't modified, otherwise send
197
                # the whole object. Support both ETags and Last-Modified dates!
198
                if len(if_range) > 1 and if_range[:2] == 'ts':
1✔
199
                    # ETag:
200
                    if if_range != self.http__etag():
1✔
201
                        # Modified, so send a normal response. We delete
202
                        # the ranges, which causes us to skip to the 200
203
                        # response.
204
                        ranges = None
1✔
205
                else:
206
                    # Date
207
                    date = if_range.split(';')[0]
1✔
208
                    try:
1✔
209
                        mod_since = int(DateTime(date).timeTime())
1✔
210
                    except Exception:
1✔
211
                        mod_since = None
1✔
212
                    if mod_since is not None:
1✔
213
                        if self._p_mtime:
1!
214
                            last_mod = int(self._p_mtime)
1✔
215
                        else:
216
                            last_mod = 0
×
217
                        if last_mod > mod_since:
1✔
218
                            # Modified, so send a normal response. We delete
219
                            # the ranges, which causes us to skip to the 200
220
                            # response.
221
                            ranges = None
1✔
222

223
            if ranges:
1✔
224
                # Search for satisfiable ranges.
225
                satisfiable = 0
1✔
226
                for start, end in ranges:
1✔
227
                    if start < self.size:
1✔
228
                        satisfiable = 1
1✔
229
                        break
1✔
230

231
                if not satisfiable:
1✔
232
                    RESPONSE.setHeader(
1✔
233
                        'Content-Range', 'bytes */%d' % self.size
234
                    )
235
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
236
                    RESPONSE.setHeader(
1✔
237
                        'Last-Modified', rfc1123_date(self._p_mtime)
238
                    )
239
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
240
                    RESPONSE.setHeader('Content-Length', self.size)
1✔
241
                    RESPONSE.setStatus(416)
1✔
242
                    return True
1✔
243

244
                ranges = HTTPRangeSupport.expandRanges(ranges, self.size)
1✔
245

246
                if len(ranges) == 1:
1✔
247
                    # Easy case, set extra header and return partial set.
248
                    start, end = ranges[0]
1✔
249
                    size = end - start
1✔
250

251
                    RESPONSE.setHeader(
1✔
252
                        'Last-Modified', rfc1123_date(self._p_mtime)
253
                    )
254
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
255
                    RESPONSE.setHeader('Content-Length', size)
1✔
256
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
257
                    RESPONSE.setHeader(
1✔
258
                        'Content-Range',
259
                        'bytes %d-%d/%d' % (start, end - 1, self.size)
260
                    )
261
                    RESPONSE.setStatus(206)  # Partial content
1✔
262

263
                    data = self.data
1✔
264
                    if isinstance(data, bytes):
1✔
265
                        RESPONSE.write(data[start:end])
1✔
266
                        return True
1✔
267

268
                    # Linked Pdata objects. Urgh.
269
                    pos = 0
1✔
270
                    while data is not None:
1!
271
                        length = len(data.data)
1✔
272
                        pos = pos + length
1✔
273
                        if pos > start:
1✔
274
                            # We are within the range
275
                            lstart = length - (pos - start)
1✔
276

277
                            if lstart < 0:
1!
278
                                lstart = 0
×
279

280
                            # find the endpoint
281
                            if end <= pos:
1!
282
                                lend = length - (pos - end)
1✔
283

284
                                # Send and end transmission
285
                                RESPONSE.write(data[lstart:lend])
1✔
286
                                break
1✔
287

288
                            # Not yet at the end, transmit what we have.
289
                            RESPONSE.write(data[lstart:])
×
290

291
                        data = data.next
1✔
292

293
                    return True
1✔
294

295
                else:
296
                    boundary = _make_boundary()
1✔
297

298
                    # Calculate the content length
299
                    size = (8 + len(boundary)  # End marker length
1✔
300
                            + len(ranges) * (  # Constant lenght per set
301
                                49 + len(boundary)
302
                                + len(self.content_type)
303
                                + len('%d' % self.size)))
304
                    for start, end in ranges:
1✔
305
                        # Variable length per set
306
                        size = (size + len('%d%d' % (start, end - 1))
1✔
307
                                + end - start)
308

309
                    # Some clients implement an earlier draft of the spec, they
310
                    # will only accept x-byteranges.
311
                    draftprefix = (request_range is not None) and 'x-' or ''
1✔
312

313
                    RESPONSE.setHeader('Content-Length', size)
1✔
314
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
315
                    RESPONSE.setHeader(
1✔
316
                        'Last-Modified', rfc1123_date(self._p_mtime)
317
                    )
318
                    RESPONSE.setHeader(
1✔
319
                        'Content-Type',
320
                        f'multipart/{draftprefix}byteranges;'
321
                        f' boundary={boundary}'
322
                    )
323
                    RESPONSE.setStatus(206)  # Partial content
1✔
324

325
                    data = self.data
1✔
326
                    # The Pdata map allows us to jump into the Pdata chain
327
                    # arbitrarily during out-of-order range searching.
328
                    pdata_map = {}
1✔
329
                    pdata_map[0] = data
1✔
330

331
                    for start, end in ranges:
1✔
332
                        RESPONSE.write(
1✔
333
                            b'\r\n--'
334
                            + boundary.encode('ascii')
335
                            + b'\r\n'
336
                        )
337
                        RESPONSE.write(
1✔
338
                            b'Content-Type: '
339
                            + self.content_type.encode('ascii')
340
                            + b'\r\n'
341
                        )
342
                        RESPONSE.write(
1✔
343
                            b'Content-Range: bytes '
344
                            + str(start).encode('ascii')
345
                            + b'-'
346
                            + str(end - 1).encode('ascii')
347
                            + b'/'
348
                            + str(self.size).encode('ascii')
349
                            + b'\r\n\r\n'
350
                        )
351

352
                        if isinstance(data, bytes):
1✔
353
                            RESPONSE.write(data[start:end])
1✔
354

355
                        else:
356
                            # Yippee. Linked Pdata objects. The following
357
                            # calculations allow us to fast-forward through the
358
                            # Pdata chain without a lot of dereferencing if we
359
                            # did the work already.
360
                            first_size = len(pdata_map[0].data)
1✔
361
                            if start < first_size:
1✔
362
                                closest_pos = 0
1✔
363
                            else:
364
                                closest_pos = (
1✔
365
                                    ((start - first_size) >> 16 << 16)
366
                                    + first_size
367
                                )
368
                            pos = min(closest_pos, max(pdata_map.keys()))
1✔
369
                            data = pdata_map[pos]
1✔
370

371
                            while data is not None:
1!
372
                                length = len(data.data)
1✔
373
                                pos = pos + length
1✔
374
                                if pos > start:
1✔
375
                                    # We are within the range
376
                                    lstart = length - (pos - start)
1✔
377

378
                                    if lstart < 0:
1✔
379
                                        lstart = 0
1✔
380

381
                                    # find the endpoint
382
                                    if end <= pos:
1✔
383
                                        lend = length - (pos - end)
1✔
384

385
                                        # Send and loop to next range
386
                                        RESPONSE.write(data[lstart:lend])
1✔
387
                                        break
1✔
388

389
                                    # Not yet at the end,
390
                                    # transmit what we have.
391
                                    RESPONSE.write(data[lstart:])
1✔
392

393
                                data = data.next
1✔
394
                                # Store a reference to a Pdata chain link
395
                                # so we don't have to deref during
396
                                # this request again.
397
                                pdata_map[pos] = data
1✔
398

399
                    # Do not keep the link references around.
400
                    del pdata_map
1✔
401

402
                    RESPONSE.write(
1✔
403
                        b'\r\n--' + boundary.encode('ascii') + b'--\r\n')
404
                    return True
1✔
405

406
    @security.protected(View)
1✔
407
    def index_html(self, REQUEST, RESPONSE):
1✔
408
        """
409
        The default view of the contents of a File or Image.
410

411
        Returns the contents of the file or image.  Also, sets the
412
        Content-Type HTTP header to the objects content type.
413
        """
414

415
        if self._if_modified_since_request_handler(REQUEST, RESPONSE):
1✔
416
            # we were able to handle this by returning a 304
417
            # unfortunately, because the HTTP cache manager uses the cache
418
            # API, and because 304 responses are required to carry the Expires
419
            # header for HTTP/1.1, we need to call ZCacheable_set here.
420
            # This is nonsensical for caches other than the HTTP cache manager
421
            # unfortunately.
422
            self.ZCacheable_set(None)
1✔
423
            return b''
1✔
424

425
        if self.precondition and hasattr(self, str(self.precondition)):
1!
426
            # Grab whatever precondition was defined and then
427
            # execute it.  The precondition will raise an exception
428
            # if something violates its terms.
429
            c = getattr(self, str(self.precondition))
×
430
            if hasattr(c, 'isDocTemp') and c.isDocTemp:
×
431
                c(REQUEST['PARENTS'][1], REQUEST)
×
432
            else:
433
                c()
×
434

435
        if self._range_request_handler(REQUEST, RESPONSE):
1✔
436
            # we served a chunk of content in response to a range request.
437
            return b''
1✔
438

439
        RESPONSE.setHeader('Last-Modified', rfc1123_date(self._p_mtime))
1✔
440
        RESPONSE.setHeader('Content-Type', self.content_type)
1✔
441
        RESPONSE.setHeader('Content-Length', self.size)
1✔
442
        RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
443

444
        if self.ZCacheable_isCachingEnabled():
1✔
445
            result = self.ZCacheable_get(default=None)
1✔
446
            if result is not None:
1!
447
                # We will always get None from RAMCacheManager and HTTP
448
                # Accelerated Cache Manager but we will get
449
                # something implementing the IStreamIterator interface
450
                # from a "FileCacheManager"
451
                return result
×
452

453
        self.ZCacheable_set(None)
1✔
454

455
        data = self.data
1✔
456
        if isinstance(data, bytes):
1✔
457
            RESPONSE.setBase(None)
1✔
458
            return data
1✔
459

460
        while data is not None:
1✔
461
            RESPONSE.write(data.data)
1✔
462
            data = data.next
1✔
463

464
        return b''
1✔
465

466
    @security.protected(View)
1✔
467
    def view_image_or_file(self, URL1):
1✔
468
        """The default view of the contents of the File or Image."""
469
        raise Redirect(URL1)
1✔
470

471
    @security.protected(View)
1✔
472
    def PrincipiaSearchSource(self):
1✔
473
        """Allow file objects to be searched."""
474
        if self.content_type.startswith('text/'):
1✔
475
            return bytes(self.data)
1✔
476
        return b''
1✔
477

478
    @security.private
1✔
479
    def update_data(self, data, content_type=None, size=None):
1✔
480
        if isinstance(data, str):
1✔
481
            raise TypeError('Data can only be bytes or file-like. '
1✔
482
                            'Unicode objects are expressly forbidden.')
483

484
        if content_type is not None:
1✔
485
            self.content_type = content_type
1✔
486
        if size is None:
1✔
487
            size = len(data)
1✔
488
        self.size = size
1✔
489
        self.data = data
1✔
490
        self.ZCacheable_invalidate()
1✔
491
        self.ZCacheable_set(None)
1✔
492
        self.http__refreshEtag()
1✔
493

494
    def _get_encoding(self):
1✔
495
        """Get the canonical encoding for ZMI."""
496
        return ZPublisher.HTTPRequest.default_encoding
1✔
497

498
    @security.protected(change_images_and_files)
1✔
499
    def manage_edit(
1✔
500
        self,
501
        title,
502
        content_type,
503
        precondition='',
504
        filedata=None,
505
        REQUEST=None
506
    ):
507
        """
508
        Changes the title and content type attributes of the File or Image.
509
        """
510
        if self.wl_isLocked():
1!
511
            raise ResourceLockedError("File is locked.")
×
512

513
        self.title = str(title)
1✔
514
        self.content_type = str(content_type)
1✔
515
        if precondition:
1!
516
            self.precondition = str(precondition)
×
517
        elif self.precondition:
1!
518
            del self.precondition
×
519
        if filedata is not None:
1✔
520
            if isinstance(filedata, str):
1✔
521
                filedata = filedata.encode(self._get_encoding())
1✔
522
            self.update_data(filedata, content_type, len(filedata))
1✔
523
        else:
524
            self.ZCacheable_invalidate()
1✔
525

526
        notify(ObjectModifiedEvent(self))
1✔
527

528
        if REQUEST:
1✔
529
            message = "Saved changes."
1✔
530
            return self.manage_main(
1✔
531
                self, REQUEST, manage_tabs_message=message)
532

533
    @security.protected(change_images_and_files)
1✔
534
    def manage_upload(self, file='', REQUEST=None):
1✔
535
        """
536
        Replaces the current contents of the File or Image object with file.
537

538
        The file or images contents are replaced with the contents of 'file'.
539
        """
540
        if self.wl_isLocked():
1!
541
            raise ResourceLockedError("File is locked.")
×
542

543
        if file:
1✔
544
            data, size = self._read_data(file)
1✔
545
            content_type = self._get_content_type(file, data, self.__name__,
1✔
546
                                                  'application/octet-stream')
547
            self.update_data(data, content_type, size)
1✔
548
            notify(ObjectModifiedEvent(self))
1✔
549
            msg = 'Saved changes.'
1✔
550
        else:
551
            msg = 'Please select a file to upload.'
1✔
552

553
        if REQUEST:
1✔
554
            return self.manage_main(
1✔
555
                self, REQUEST, manage_tabs_message=msg)
556

557
    def _get_content_type(self, file, body, id, content_type=None):
1✔
558
        headers = getattr(file, 'headers', None)
1✔
559
        if headers and 'content-type' in headers:
1✔
560
            content_type = headers['content-type']
1✔
561
        else:
562
            if not isinstance(body, bytes):
1✔
563
                body = body.data
1✔
564
            content_type, enc = guess_content_type(
1✔
565
                getattr(file, 'filename', id), body, content_type)
566
        return content_type
1✔
567

568
    def _read_data(self, file):
1✔
569
        import transaction
1✔
570

571
        n = 1 << 16
1✔
572

573
        if isinstance(file, str):
1!
574
            raise ValueError("Must be bytes")
×
575

576
        if isinstance(file, bytes):
1✔
577
            size = len(file)
1✔
578
            if size < n:
1✔
579
                return (file, size)
1✔
580
            # Big string: cut it into smaller chunks
581
            file = BytesIO(file)
1✔
582

583
        if isinstance(file, FileUpload) and not file:
1!
584
            raise ValueError('File not specified')
×
585

586
        if hasattr(file, '__class__') and file.__class__ is Pdata:
1!
587
            size = len(file)
×
588
            return (file, size)
×
589

590
        seek = file.seek
1✔
591
        read = file.read
1✔
592

593
        seek(0, 2)
1✔
594
        size = end = file.tell()
1✔
595

596
        if size <= 2 * n:
1✔
597
            seek(0)
1✔
598
            if size < n:
1✔
599
                return read(size), size
1✔
600
            return Pdata(read(size)), size
1✔
601

602
        # Make sure we have an _p_jar, even if we are a new object, by
603
        # doing a sub-transaction commit.
604
        transaction.savepoint(optimistic=True)
1✔
605

606
        if self._p_jar is None:
1!
607
            # Ugh
608
            seek(0)
×
609
            return Pdata(read(size)), size
×
610

611
        # Now we're going to build a linked list from back
612
        # to front to minimize the number of database updates
613
        # and to allow us to get things out of memory as soon as
614
        # possible.
615
        _next = None
1✔
616
        while end > 0:
1✔
617
            pos = end - n
1✔
618
            if pos < n:
1✔
619
                pos = 0  # we always want at least n bytes
1✔
620
            seek(pos)
1✔
621

622
            # Create the object and assign it a next pointer
623
            # in the same transaction, so that there is only
624
            # a single database update for it.
625
            data = Pdata(read(end - pos))
1✔
626
            self._p_jar.add(data)
1✔
627
            data.next = _next
1✔
628

629
            # Save the object so that we can release its memory.
630
            transaction.savepoint(optimistic=True)
1✔
631
            data._p_deactivate()
1✔
632
            # The object should be assigned an oid and be a ghost.
633
            assert data._p_oid is not None
1✔
634
            assert data._p_state == -1
1✔
635

636
            _next = data
1✔
637
            end = pos
1✔
638

639
        return (_next, size)
1✔
640

641
    @security.protected(change_images_and_files)
1✔
642
    def PUT(self, REQUEST, RESPONSE):
1✔
643
        """Handle HTTP PUT requests"""
644
        self.dav__init(REQUEST, RESPONSE)
1✔
645
        self.dav__simpleifhandler(REQUEST, RESPONSE, refresh=1)
1✔
646
        type = REQUEST.get_header('content-type', None)
1✔
647

648
        file = REQUEST['BODYFILE']
1✔
649

650
        data, size = self._read_data(file)
1✔
651
        if isinstance(data, str):
1!
652
            data = data.encode('UTF-8')
×
653
        content_type = self._get_content_type(file, data, self.__name__,
1✔
654
                                              type or self.content_type)
655
        self.update_data(data, content_type, size)
1✔
656

657
        RESPONSE.setStatus(204)
1✔
658
        return RESPONSE
1✔
659

660
    @security.protected(View)
1✔
661
    def get_size(self):
1✔
662
        # Get the size of a file or image.
663
        # Returns the size of the file or image.
664
        size = self.size
1✔
665
        if size is None:
1!
666
            size = len(self.data)
×
667
        return size
1✔
668

669
    # deprecated; use get_size!
670
    getSize = get_size
1✔
671

672
    @security.protected(View)
1✔
673
    def getContentType(self):
1✔
674
        # Get the content type of a file or image.
675
        # Returns the content type (MIME type) of a file or image.
676
        return self.content_type
1✔
677

678
    def __bytes__(self):
1✔
679
        return bytes(self.data)
×
680

681
    def __str__(self):
1✔
682
        """In most cases, this is probably not what you want. Use ``bytes``."""
683
        if isinstance(self.data, Pdata):
1✔
684
            return bytes(self.data).decode(self._get_encoding())
1✔
685
        else:
686
            return self.data.decode(self._get_encoding())
1✔
687

688
    def __bool__(self):
1✔
689
        return True
1✔
690

691
    __nonzero__ = __bool__
1✔
692

693
    def __len__(self):
1✔
694
        data = bytes(self.data)
×
695
        return len(data)
×
696

697
    @security.protected(webdav_access)
1✔
698
    def manage_DAVget(self):
1✔
699
        """Return body for WebDAV."""
700
        RESPONSE = self.REQUEST.RESPONSE
1✔
701

702
        if self.ZCacheable_isCachingEnabled():
1!
703
            result = self.ZCacheable_get(default=None)
1✔
704
            if result is not None:
1!
705
                # We will always get None from RAMCacheManager but we will
706
                # get something implementing the IStreamIterator interface
707
                # from FileCacheManager.
708
                # the content-length is required here by HTTPResponse.
709
                RESPONSE.setHeader('Content-Length', self.size)
×
710
                return result
×
711

712
        data = self.data
1✔
713
        if isinstance(data, bytes):
1!
714
            RESPONSE.setBase(None)
1✔
715
            return data
1✔
716

717
        while data is not None:
×
718
            RESPONSE.write(data.data)
×
719
            data = data.next
×
720

721
        return b''
×
722

723

724
InitializeClass(File)
1✔
725

726

727
manage_addImageForm = DTMLFile(
1✔
728
    'dtml/imageAdd',
729
    globals(),
730
    Kind='Image',
731
    kind='image',
732
)
733

734

735
def manage_addImage(
1✔
736
    self,
737
    id,
738
    file,
739
    title='',
740
    precondition='',
741
    content_type='',
742
    REQUEST=None
743
):
744
    """
745
    Add a new Image object.
746

747
    Creates a new Image object 'id' with the contents of 'file'.
748
    """
749
    id = str(id)
1✔
750
    title = str(title)
1✔
751
    content_type = str(content_type)
1✔
752
    precondition = str(precondition)
1✔
753

754
    id, title = cookId(id, title, file)
1✔
755

756
    self = self.this()
1✔
757

758
    # First, we create the image without data:
759
    self._setObject(id, Image(id, title, b'', content_type, precondition))
1✔
760

761
    newFile = self._getOb(id)
1✔
762

763
    # Now we "upload" the data.  By doing this in two steps, we
764
    # can use a database trick to make the upload more efficient.
765
    if file:
1!
766
        newFile.manage_upload(file)
1✔
767
    if content_type:
1!
768
        newFile.content_type = content_type
1✔
769

770
    notify(ObjectCreatedEvent(newFile))
1✔
771

772
    if REQUEST is not None:
1!
773
        try:
×
774
            url = self.DestinationURL()
×
775
        except Exception:
×
776
            url = REQUEST['URL1']
×
777
        REQUEST.RESPONSE.redirect('%s/manage_main' % url)
×
778
    return id
1✔
779

780

781
def getImageInfo(data):
1✔
782
    data = bytes(data)
1✔
783
    size = len(data)
1✔
784
    height = -1
1✔
785
    width = -1
1✔
786
    content_type = ''
1✔
787

788
    # handle GIFs
789
    if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'):
1✔
790
        # Check to see if content_type is correct
791
        content_type = 'image/gif'
1✔
792
        w, h = struct.unpack("<HH", data[6:10])
1✔
793
        width = int(w)
1✔
794
        height = int(h)
1✔
795

796
    # See PNG v1.2 spec (http://www.cdrom.com/pub/png/spec/)
797
    # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
798
    # and finally the 4-byte width, height
799
    elif (size >= 24
1!
800
          and data[:8] == b'\211PNG\r\n\032\n'
801
          and data[12:16] == b'IHDR'):
802
        content_type = 'image/png'
×
803
        w, h = struct.unpack(">LL", data[16:24])
×
804
        width = int(w)
×
805
        height = int(h)
×
806

807
    # Maybe this is for an older PNG version.
808
    elif (size >= 16) and (data[:8] == b'\211PNG\r\n\032\n'):
1!
809
        # Check to see if we have the right content type
810
        content_type = 'image/png'
×
811
        w, h = struct.unpack(">LL", data[8:16])
×
812
        width = int(w)
×
813
        height = int(h)
×
814

815
    # handle JPEGs
816
    elif (size >= 2) and (data[:2] == b'\377\330'):
1!
817
        content_type = 'image/jpeg'
×
818
        jpeg = BytesIO(data)
×
819
        jpeg.read(2)
×
820
        b = jpeg.read(1)
×
821
        try:
×
822
            while (b and ord(b) != 0xDA):
×
823
                while (ord(b) != 0xFF):
×
824
                    b = jpeg.read(1)
×
825
                while (ord(b) == 0xFF):
×
826
                    b = jpeg.read(1)
×
827
                if (ord(b) >= 0xC0 and ord(b) <= 0xC3):
×
828
                    jpeg.read(3)
×
829
                    h, w = struct.unpack(">HH", jpeg.read(4))
×
830
                    break
×
831
                else:
832
                    jpeg.read(int(struct.unpack(">H", jpeg.read(2))[0]) - 2)
×
833
                b = jpeg.read(1)
×
834
            width = int(w)
×
835
            height = int(h)
×
836
        except Exception:
×
837
            pass
×
838

839
    # handle SVGs
840
    elif (size >= 16) and (b'<?xml' in data[:16]):
1!
841
        content_type = 'image/svg+xml'
×
842
        try:
×
843
            xmldoc = minidom.parseString(data)
×
844
        except Exception:
×
845
            return content_type, width, height
×
846
        for svg in xmldoc.getElementsByTagName('svg'):
×
847
            if 'height' in svg.attributes and 'width' in svg.attributes:
×
848
              w = svg.attributes['width'].value
×
849
              h = svg.attributes['height'].value
×
850
              try:
×
851
                w = int(float(w))
×
852
                h = int(float(h))
×
853
              except:
×
854
                if str(w).endswith('px'):
×
855
                  w = int(float(w[:-2]))
×
856
                  h = int(float(h[:-2]))
×
857
                elif str(w).endswith('mm'):
×
858
                  w = int(float(w[:-2]) * 3.7795)
×
859
                  h = int(float(h[:-2]) * 3.7795)
×
860
                elif str(w).endswith('cm'):
×
861
                  w = int(float(w[:-2]) * 37.795)
×
862
                  h = int(float(h[:-2]) * 37.795)
×
863
              break
×
864
            elif 'viewBox' in svg.attributes:
×
865
              viewBox = svg.attributes['viewBox'].value
×
866
              viewBox = [int(float(x)) for x in viewBox.split(' ')]
×
867
              w = viewBox[2] - viewBox[0]
×
868
              h = viewBox[3] - viewBox[1]
×
869
        width = int(w)
×
870
        height = int(h)
×
871

872
    return content_type, width, height
1✔
873

874

875
class Image(File):
1✔
876
    """Image objects can be GIF, PNG or JPEG and have the same methods
877
    as File objects.  Images also have a string representation that
878
    renders an HTML 'IMG' tag.
879
    """
880

881
    meta_type = 'Image'
1✔
882
    zmi_icon = 'far fa-file-image'
1✔
883

884
    security = ClassSecurityInfo()
1✔
885
    security.declareObjectProtected(View)
1✔
886

887
    alt = ''
1✔
888
    height = ''
1✔
889
    width = ''
1✔
890

891
    # FIXME: Redundant, already in base class
892
    security.declareProtected(change_images_and_files, 'manage_edit')  # NOQA: D001,E501
1✔
893
    security.declareProtected(change_images_and_files, 'manage_upload')  # NOQA: D001,E501
1✔
894
    security.declareProtected(View, 'index_html')  # NOQA: D001
1✔
895
    security.declareProtected(View, 'get_size')  # NOQA: D001
1✔
896
    security.declareProtected(View, 'getContentType')  # NOQA: D001
1✔
897

898
    _properties = (
1✔
899
        {'id': 'title', 'type': 'string'},
900
        {'id': 'alt', 'type': 'string'},
901
        {'id': 'content_type', 'type': 'string', 'mode': 'w'},
902
        {'id': 'height', 'type': 'string'},
903
        {'id': 'width', 'type': 'string'},
904
    )
905

906
    manage_options = (
1✔
907
        ({'label': 'Edit', 'action': 'manage_main'},
908
         {'label': 'View', 'action': 'view_image_or_file'})
909
        + PropertyManager.manage_options
910
        + RoleManager.manage_options
911
        + Item_w__name__.manage_options
912
        + Cacheable.manage_options
913
    )
914

915
    manage_editForm = DTMLFile(
1✔
916
        'dtml/imageEdit',
917
        globals(),
918
        Kind='Image',
919
        kind='image',
920
    )
921
    manage_editForm._setName('manage_editForm')
1✔
922

923
    security.declareProtected(View, 'view_image_or_file')  # NOQA: D001
1✔
924
    view_image_or_file = DTMLFile('dtml/imageView', globals())
1✔
925

926
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
927
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
928
    manage = manage_main = manage_editForm
1✔
929
    manage_uploadForm = manage_editForm
1✔
930

931
    @security.private
1✔
932
    def update_data(self, data, content_type=None, size=None):
1✔
933
        if isinstance(data, str):
1✔
934
            raise TypeError('Data can only be bytes or file-like.  '
1✔
935
                            'Unicode objects are expressly forbidden.')
936

937
        if size is None:
1✔
938
            size = len(data)
1✔
939

940
        self.size = size
1✔
941
        self.data = data
1✔
942

943
        ct, width, height = getImageInfo(data)
1✔
944
        if ct:
1✔
945
            content_type = ct
1✔
946
        if width >= 0 and height >= 0:
1✔
947
            self.width = width
1✔
948
            self.height = height
1✔
949

950
        # Now we should have the correct content type, or still None
951
        if content_type is not None:
1!
952
            self.content_type = content_type
1✔
953

954
        self.ZCacheable_invalidate()
1✔
955
        self.ZCacheable_set(None)
1✔
956
        self.http__refreshEtag()
1✔
957

958
    def __bytes__(self):
1✔
959
        return self.tag().encode('utf-8')
×
960

961
    def __str__(self):
1✔
962
        return self.tag()
1✔
963

964
    @security.protected(View)
1✔
965
    def tag(
1✔
966
        self,
967
        height=None,
968
        width=None,
969
        alt=None,
970
        scale=0,
971
        xscale=0,
972
        yscale=0,
973
        css_class=None,
974
        title=None,
975
        **args
976
    ):
977
        """Generate an HTML IMG tag for this image, with customization.
978

979
        Arguments to self.tag() can be any valid attributes of an IMG tag.
980
        'src' will always be an absolute pathname, to prevent redundant
981
        downloading of images. Defaults are applied intelligently for
982
        'height', 'width', and 'alt'. If specified, the 'scale', 'xscale',
983
        and 'yscale' keyword arguments will be used to automatically adjust
984
        the output height and width values of the image tag.
985
        #
986
        Since 'class' is a Python reserved word, it cannot be passed in
987
        directly in keyword arguments which is a problem if you are
988
        trying to use 'tag()' to include a CSS class. The tag() method
989
        will accept a 'css_class' argument that will be converted to
990
        'class' in the output tag to work around this.
991
        """
992
        if height is None:
1!
993
            height = self.height
1✔
994
        if width is None:
1!
995
            width = self.width
1✔
996

997
        # Auto-scaling support
998
        xdelta = xscale or scale
1✔
999
        ydelta = yscale or scale
1✔
1000

1001
        if xdelta and width:
1!
1002
            width = str(int(round(int(width) * xdelta)))
×
1003
        if ydelta and height:
1!
1004
            height = str(int(round(int(height) * ydelta)))
×
1005

1006
        result = '<img src="%s"' % (self.absolute_url())
1✔
1007

1008
        if alt is None:
1!
1009
            alt = getattr(self, 'alt', '')
1✔
1010
        result = f'{result} alt="{html.escape(alt, True)}"'
1✔
1011

1012
        if title is None:
1!
1013
            title = getattr(self, 'title', '')
1✔
1014
        result = f'{result} title="{html.escape(title, True)}"'
1✔
1015

1016
        if height:
1!
1017
            result = f'{result} height="{height}"'
1✔
1018

1019
        if width:
1!
1020
            result = f'{result} width="{width}"'
1✔
1021

1022
        if css_class is not None:
1!
1023
            result = f'{result} class="{css_class}"'
×
1024

1025
        for key in list(args.keys()):
1!
1026
            value = args.get(key)
×
1027
            if value:
×
1028
                result = f'{result} {key}="{value}"'
×
1029

1030
        return '%s />' % result
1✔
1031

1032

1033
InitializeClass(Image)
1✔
1034

1035

1036
def cookId(id, title, file):
1✔
1037
    if not id and hasattr(file, 'filename'):
1!
1038
        filename = file.filename
×
1039
        title = title or filename
×
1040
        id = filename[max(filename.rfind('/'),
×
1041
                          filename.rfind('\\'),
1042
                          filename.rfind(':'),
1043
                          ) + 1:]
1044
    return id, title
1✔
1045

1046

1047
class Pdata(Persistent, Implicit):
1✔
1048
    # Wrapper for possibly large data
1049

1050
    next = None
1✔
1051

1052
    def __init__(self, data):
1✔
1053
        self.data = data
1✔
1054

1055
    def __getitem__(self, key):
1✔
1056
        return self.data[key]
1✔
1057

1058
    def __len__(self):
1✔
1059
        data = bytes(self)
×
1060
        return len(data)
×
1061

1062
    def __bytes__(self):
1✔
1063
        _next = self.next
1✔
1064
        if _next is None:
1!
1065
            return self.data
1✔
1066

1067
        r = [self.data]
×
1068
        while _next is not None:
×
1069
            self = _next
×
1070
            r.append(self.data)
×
1071
            _next = self.next
×
1072

1073
        return b''.join(r)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc