Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
_utils.py647 linesDownload Raw Back to pypdf
1# Copyright (c) 2006, Mathieu Fenniak2# All rights reserved.3#4# Redistribution and use in source and binary forms, with or without5# modification, are permitted provided that the following conditions are6# met:7#8# * Redistributions of source code must retain the above copyright notice,9# this list of conditions and the following disclaimer.10# * Redistributions in binary form must reproduce the above copyright notice,11# this list of conditions and the following disclaimer in the documentation12# and/or other materials provided with the distribution.13# * The name of the author may not be used to endorse or promote products14# derived from this software without specific prior written permission.15#16# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"17# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE18# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE19# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE20# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR21# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF22# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS23# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN24# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)25# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE26# POSSIBILITY OF SUCH DAMAGE.27 28"""Utility functions for PDF library."""29__author__ = "Mathieu Fenniak"30__author_email__ = "biziqe@mathieu.fenniak.net"31 32import functools33import logging34import re35import sys36import warnings37from dataclasses import dataclass38from datetime import datetime, timezone39from io import DEFAULT_BUFFER_SIZE40from os import SEEK_CUR41from re import Pattern42from typing import (43    IO,44    Any,45    Optional,46    Union,47    overload,48)49 50if sys.version_info[:2] >= (3, 10):51    # Python 3.10+: https://www.python.org/dev/peps/pep-0484/52    from typing import TypeAlias53else:54    from typing_extensions import TypeAlias55 56if sys.version_info >= (3, 11):57    from typing import Self58else:59    from typing_extensions import Self60 61from .errors import (62    STREAM_TRUNCATED_PREMATURELY,63    DeprecationError,64    PdfStreamError,65)66 67TransformationMatrixType: TypeAlias = tuple[68    tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]69]70CompressedTransformationMatrix: TypeAlias = tuple[71    float, float, float, float, float, float72]73 74StreamType = IO[Any]75BinaryStreamType = IO[bytes]76StrByteType = Union[str, StreamType]77 78 79def parse_iso8824_date(text: Optional[str]) -> Optional[datetime]:80    orgtext = text81    if not text:82        return None83    if text[0].isdigit():84        text = "D:" + text85    if text.endswith(("Z", "z")):86        text += "0000"87    text = text.replace("z", "+").replace("Z", "+").replace("'", "")88    i = max(text.find("+"), text.find("-"))89    if i > 0 and i != len(text) - 5:90        text += "00"91    for f in (92        "D:%Y",93        "D:%Y%m",94        "D:%Y%m%d",95        "D:%Y%m%d%H",96        "D:%Y%m%d%H%M",97        "D:%Y%m%d%H%M%S",98        "D:%Y%m%d%H%M%S%z",99    ):100        try:101            d = datetime.strptime(text, f)  # noqa: DTZ007102        except ValueError:103            continue104        else:105            if text.endswith("+0000"):106                d = d.replace(tzinfo=timezone.utc)107            return d108    raise ValueError(f"Can not convert date: {orgtext}")109 110 111def format_iso8824_date(dt: datetime) -> str:112    """113    Convert a datetime object to PDF date string format.114 115    Converts datetime to the PDF date format D:YYYYMMDDHHmmSSOHH'mm116    as specified in the PDF Reference.117 118    Args:119        dt: A datetime object to convert.120 121    Returns:122        A date string in PDF format.123    """124    date_str = dt.strftime("D:%Y%m%d%H%M%S")125    if dt.tzinfo is not None:126        offset = dt.utcoffset()127        assert offset is not None128        total_seconds = int(offset.total_seconds())129        hours, remainder = divmod(abs(total_seconds), 3600)130        minutes = remainder // 60131        sign = "+" if total_seconds >= 0 else "-"132        date_str += f"{sign}{hours:02d}'{minutes:02d}'"133    return date_str134 135 136def _get_max_pdf_version_header(header1: str, header2: str) -> str:137    versions = (138        "%PDF-1.3",139        "%PDF-1.4",140        "%PDF-1.5",141        "%PDF-1.6",142        "%PDF-1.7",143        "%PDF-2.0",144    )145    pdf_header_indices = []146    if header1 in versions:147        pdf_header_indices.append(versions.index(header1))148    if header2 in versions:149        pdf_header_indices.append(versions.index(header2))150    if len(pdf_header_indices) == 0:151        raise ValueError(f"Neither {header1!r} nor {header2!r} are proper headers")152    return versions[max(pdf_header_indices)]153 154 155WHITESPACES = (b"\x00", b"\t", b"\n", b"\f", b"\r", b" ")156WHITESPACES_AS_BYTES = b"".join(WHITESPACES)157WHITESPACES_AS_REGEXP = b"[" + WHITESPACES_AS_BYTES + b"]"158 159 160def read_until_whitespace(stream: StreamType, maxchars: Optional[int] = None) -> bytes:161    """162    Read non-whitespace characters and return them.163 164    Stops upon encountering whitespace or when maxchars is reached.165 166    Args:167        stream: The data stream from which was read.168        maxchars: The maximum number of bytes returned; by default unlimited.169 170    Returns:171        The data which was read.172 173    """174    txt = b""175    while True:176        tok = stream.read(1)177        if tok.isspace() or not tok:178            break179        txt += tok180        if len(txt) == maxchars:181            break182    return txt183 184 185def read_non_whitespace(stream: BinaryStreamType) -> bytes:186    """187    Find and read the next non-whitespace character (ignores whitespace).188 189    Args:190        stream: The data stream from which was read.191 192    Returns:193        The data which was read.194 195    """196    tok = stream.read(1)197    while tok in WHITESPACES:198        tok = stream.read(1)199    return tok200 201 202def skip_over_whitespace(stream: StreamType) -> bool:203    """204    Similar to read_non_whitespace, but return a boolean if at least one205    whitespace character was read.206 207    Args:208        stream: The data stream from which was read.209 210    Returns:211        True if one or more whitespace was skipped, otherwise return False.212 213    """214    tok = stream.read(1)215    cnt = 0216    while tok in WHITESPACES:217        cnt += 1218        tok = stream.read(1)219    return cnt > 0220 221 222def check_if_whitespace_only(value: bytes) -> bool:223    """224    Check if the given value consists of whitespace characters only.225 226    Args:227        value: The bytes to check.228 229    Returns:230        True if the value only has whitespace characters, otherwise return False.231 232    """233    return all(b in WHITESPACES_AS_BYTES for b in value)234 235 236def skip_over_comment(stream: StreamType) -> None:237    tok = stream.read(1)238    stream.seek(-1, 1)239    if tok == b"%":240        while tok not in (b"\n", b"\r"):241            tok = stream.read(1)242            if tok == b"":243                raise PdfStreamError("File ended unexpectedly.")244 245 246def read_until_regex(stream: StreamType, regex: Pattern[bytes]) -> bytes:247    """248    Read until the regular expression pattern matched (ignore the match).249    Treats EOF on the underlying stream as the end of the token to be matched.250 251    Args:252        regex: re.Pattern253 254    Returns:255        The read bytes.256 257    """258    parts: list[bytes] = []259    total_len = 0260    tail = b""261    chunk_size = 16262    while True:263        tok = stream.read(chunk_size)264        if not tok:265            return b"".join(parts)266        # Search overlap of previous tail + new chunk to catch267        # multi-byte regex matches spanning chunk boundaries.268        buf = tail + tok269        m = regex.search(buf)270        if m is not None:271            overlap = len(tail)272            actual_start = total_len - overlap + m.start()273            stream.seek(actual_start - total_len - len(tok), 1)274            parts.append(tok)275            return b"".join(parts)[:actual_start]276        parts.append(tok)277        total_len += len(tok)278        # Fixed overlap: 16 bytes is sufficient for the short279        # delimiter patterns used in PDF parsing.280        tail = tok[-16:]281        if chunk_size < 8192:282            chunk_size <<= 1283    return b"".join(parts)284 285 286def read_block_backwards(stream: BinaryStreamType, to_read: int) -> bytes:287    """288    Given a stream at position X, read a block of size to_read ending at position X.289 290    This changes the stream's position to the beginning of where the block was291    read.292 293    Args:294        stream:295        to_read:296 297    Returns:298        The data which was read.299 300    """301    if stream.tell() < to_read:302        raise PdfStreamError("Could not read malformed PDF file")303    # Seek to the start of the block we want to read.304    stream.seek(-to_read, SEEK_CUR)305    read = stream.read(to_read)306    # Seek to the start of the block we read after reading it.307    stream.seek(-to_read, SEEK_CUR)308    return read309 310 311def read_previous_line(stream: StreamType) -> bytes:312    """313    Given a byte stream with current position X, return the previous line.314 315    All characters between the first CR/LF byte found before X316    (or, the start of the file, if no such byte is found) and position X317    After this call, the stream will be positioned one byte after the318    first non-CRLF character found beyond the first CR/LF byte before X,319    or, if no such byte is found, at the beginning of the stream.320 321    Args:322        stream: StreamType:323 324    Returns:325        The data which was read.326 327    """328    line_content = []329    found_crlf = False330    if stream.tell() == 0:331        raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)332    while True:333        to_read = min(DEFAULT_BUFFER_SIZE, stream.tell())334        if to_read == 0:335            break336        # Read the block. After this, our stream will be one337        # beyond the initial position.338        block = read_block_backwards(stream, to_read)339        idx = len(block) - 1340        if not found_crlf:341            # We haven't found our first CR/LF yet.342            # Read off characters until we hit one.343            while idx >= 0 and block[idx] not in b"\r\n":344                idx -= 1345            if idx >= 0:346                found_crlf = True347        if found_crlf:348            # We found our first CR/LF already (on this block or349            # a previous one).350            # Our combined line is the remainder of the block351            # plus any previously read blocks.352            line_content.append(block[idx + 1 :])353            # Continue to read off any more CRLF characters.354            while idx >= 0 and block[idx] in b"\r\n":355                idx -= 1356        else:357            # Didn't find CR/LF yet - add this block to our358            # previously read blocks and continue.359            line_content.append(block)360        if idx >= 0:361            # We found the next non-CRLF character.362            # Set the stream position correctly, then break363            stream.seek(idx + 1, SEEK_CUR)364            break365    # Join all the blocks in the line (which are in reverse order)366    return b"".join(line_content[::-1])367 368 369def matrix_multiply(370    a: TransformationMatrixType, b: TransformationMatrixType371) -> TransformationMatrixType:372    return tuple(  # type: ignore[return-value]373        tuple(sum(float(i) * float(j) for i, j in zip(row, col)) for col in zip(*b))374        for row in a375    )376 377 378def mark_location(stream: StreamType) -> None:379    """Create text file showing current location in context."""380    # Mainly for debugging381    radius = 5000382    stream.seek(-radius, 1)383    with open("pypdf_pdfLocation.txt", "wb") as output_fh:384        output_fh.write(stream.read(radius))385        output_fh.write(b"HERE")386        output_fh.write(stream.read(radius))387    stream.seek(-radius, 1)388 389 390@overload391def ord_(b: str) -> int:392    ...393 394 395@overload396def ord_(b: bytes) -> bytes:397    ...398 399 400@overload401def ord_(b: int) -> int:402    ...403 404 405def ord_(b: Union[int, str, bytes]) -> Union[int, bytes]:406    if isinstance(b, str):407        return ord(b)408    return b409 410 411def deprecate(msg: str, stacklevel: int = 3) -> None:412    warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel)413 414 415def deprecation(msg: str) -> None:416    raise DeprecationError(msg)417 418 419def deprecate_with_replacement(old_name: str, new_name: str, removed_in: str) -> None:420    """Issue a warning that a feature will be removed, but has a replacement."""421    deprecate(422        f"{old_name} is deprecated and will be removed in pypdf {removed_in}. Use {new_name} instead.",423        4,424    )425 426 427def deprecation_with_replacement(old_name: str, new_name: str, removed_in: str) -> None:428    """Raise an exception that a feature was already removed, but has a replacement."""429    deprecation(430        f"{old_name} is deprecated and was removed in pypdf {removed_in}. Use {new_name} instead."431    )432 433 434def deprecate_no_replacement(name: str, removed_in: str) -> None:435    """Issue a warning that a feature will be removed without replacement."""436    deprecate(f"{name} is deprecated and will be removed in pypdf {removed_in}.", 4)437 438 439def deprecation_no_replacement(name: str, removed_in: str) -> None:440    """Raise an exception that a feature was already removed without replacement."""441    deprecation(f"{name} is deprecated and was removed in pypdf {removed_in}.")442 443 444def logger_error(message: str, *, source: str, **values: Any) -> None:445    """446    Use this instead of logger.error directly.447 448    That allows people to overwrite it more easily.449 450    See the docs on when to use which:451    https://pypdf.readthedocs.io/en/latest/user/suppress-warnings.html452    """453    logging.getLogger(source).error(message, values)454 455 456def logger_warning(msg: str, src: str) -> None:457    """458    Use this instead of logger.warning directly.459 460    That allows people to overwrite it more easily.461 462    ## Exception, warnings.warn, logger_warning463    - Exceptions should be used if the user should write code that deals with464      an error case, e.g. the PDF being completely broken.465    - warnings.warn should be used if the user needs to fix their code, e.g.466      DeprecationWarnings467    - logger_warning should be used if the user needs to know that an issue was468      handled by pypdf, e.g. a non-compliant PDF being read in a way that469      pypdf could apply a robustness fix to still read it. This applies mainly470      to strict=False mode.471    """472    logging.getLogger(src).warning(msg)473 474 475def rename_kwargs(476    func_name: str, kwargs: dict[str, Any], aliases: dict[str, str], fail: bool = False477) -> None:478    """479    Helper function to deprecate arguments.480 481    Args:482        func_name: Name of the function to be deprecated483        kwargs:484        aliases:485        fail:486 487    """488    for old_term, new_term in aliases.items():489        if old_term in kwargs:490            if fail:491                raise DeprecationError(492                    f"{old_term} is deprecated as an argument. Use {new_term} instead"493                )494            if new_term in kwargs:495                raise TypeError(496                    f"{func_name} received both {old_term} and {new_term} as "497                    f"an argument. {old_term} is deprecated. "498                    f"Use {new_term} instead."499                )500            kwargs[new_term] = kwargs.pop(old_term)501            warnings.warn(502                message=(503                    f"{old_term} is deprecated as an argument. Use {new_term} instead"504                ),505                category=DeprecationWarning,506                stacklevel=3,507            )508 509 510def _human_readable_bytes(bytes: int) -> str:511    if bytes < 10**3:512        return f"{bytes} Byte"513    if bytes < 10**6:514        return f"{bytes / 10**3:.1f} kB"515    if bytes < 10**9:516        return f"{bytes / 10**6:.1f} MB"517    return f"{bytes / 10**9:.1f} GB"518 519 520# The following class has been copied from Django:521# https://github.com/django/django/blob/adae619426b6f50046b3daaa744db52989c9d6db/django/utils/functional.py#L51-L65522# It received some modifications to comply with our own coding standards.523#524# Original license:525#526# ---------------------------------------------------------------------------------527# Copyright (c) Django Software Foundation and individual contributors.528# All rights reserved.529#530# Redistribution and use in source and binary forms, with or without modification,531# are permitted provided that the following conditions are met:532#533#     1. Redistributions of source code must retain the above copyright notice,534#        this list of conditions and the following disclaimer.535#536#     2. Redistributions in binary form must reproduce the above copyright537#        notice, this list of conditions and the following disclaimer in the538#        documentation and/or other materials provided with the distribution.539#540#     3. Neither the name of Django nor the names of its contributors may be used541#        to endorse or promote products derived from this software without542#        specific prior written permission.543#544# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND545# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED546# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE547# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR548# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES549# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;550# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON551# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT552# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS553# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.554# ---------------------------------------------------------------------------------555class classproperty:  # noqa: N801556    """557    Decorator that converts a method with a single cls argument into a property558    that can be accessed directly from the class.559    """560 561    def __init__(self, method=None) -> None:  # type: ignore  # noqa: ANN001562        self.fget = method563 564    def __get__(self, instance, cls=None) -> Any:  # type: ignore  # noqa: ANN001565        return self.fget(cls)566 567    def getter(self, method) -> Self:  # type: ignore  # noqa: ANN001568        self.fget = method569        return self570 571 572@dataclass573class File:574    from .generic import IndirectObject  # noqa: PLC0415575 576    name: str = ""577    """578    Filename as identified within the PDF file.579    """580    data: bytes = b""581    """582    Data as bytes.583    """584    indirect_reference: Optional[IndirectObject] = None585    """586    Reference to the object storing the stream.587    """588 589    def __str__(self) -> str:590        return f"{self.__class__.__name__}(name={self.name}, data: {_human_readable_bytes(len(self.data))})"591 592    def __repr__(self) -> str:593        return self.__str__()[:-1] + f", hash: {hash(self.data)})"594 595 596@functools.total_ordering597class Version:598    COMPONENT_PATTERN = re.compile(r"^(\d+)(.*)$")599 600    def __init__(self, version_str: str) -> None:601        self.version_str = version_str602        self.components = self._parse_version(version_str)603 604    def _parse_version(self, version_str: str) -> list[tuple[int, str]]:605        components = version_str.split(".")606        parsed_components = []607        for component in components:608            match = Version.COMPONENT_PATTERN.match(component)609            if not match:610                parsed_components.append((0, component))611                continue612            integer_prefix = match.group(1)613            suffix = match.group(2)614            if integer_prefix is None:615                integer_prefix = 0616            parsed_components.append((int(integer_prefix), suffix))617        return parsed_components618 619    def __eq__(self, other: object) -> bool:620        if not isinstance(other, Version):621            return False622        return self.components == other.components623 624    def __hash__(self) -> int:625        # Convert to tuple as lists cannot be hashed.626        return hash((self.__class__, tuple(self.components)))627 628    def __lt__(self, other: Any) -> bool:629        if not isinstance(other, Version):630            raise ValueError(f"Version cannot be compared against {type(other)}")631 632        for self_component, other_component in zip(self.components, other.components):633            self_value, self_suffix = self_component634            other_value, other_suffix = other_component635 636            if self_value < other_value:637                return True638            if self_value > other_value:639                return False640 641            if self_suffix < other_suffix:642                return True643            if self_suffix > other_suffix:644                return False645 646        return len(self.components) < len(other.components)647 
codekingpro/portable-devtools · Team Ai