# Copyright 2010 Dirk Holtwick, holtwick.it # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import contextlib import logging import re from copy import copy from typing import Any import arabic_reshaper import reportlab import reportlab.pdfbase._cidfontdata from bidi.algorithm import get_display from reportlab.lib.colors import Color, toColor from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT from reportlab.lib.units import cm, inch from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont import xhtml2pdf.default log = logging.getLogger(__name__) rgb_re = re.compile( r"^.*?rgb[a]?[(]([0-9]+).*?([0-9]+).*?([0-9]+)(?:.*?(?:[01]\.(?:[0-9]+)))?[)].*?[ ]*$" ) # ========================================================================= # Memoize decorator # ========================================================================= class Memoized: """ A kwargs-aware memoizer, better than the one in python :). Don't pass in too large kwargs, since this turns them into a tuple of tuples. Also, avoid mutable types (as usual for memoizers) What this does is to create a dictionnary of {(*parameters):return value}, and uses it as a cache for subsequent calls to the same method. It is especially useful for functions that don't rely on external variables and that are called often. It's a perfect match for our getSize etc... """ def __init__(self, func) -> None: self.cache: dict = {} self.func = func self.__doc__ = self.func.__doc__ # To avoid great confusion self.__name__ = self.func.__name__ # This also avoids great confusion def __call__(self, *args, **kwargs): # Make sure the following line is not actually slower than what you're # trying to memoize args_plus = tuple(kwargs.items()) key = (args, args_plus) try: if key not in self.cache: res = self.func(*args, **kwargs) self.cache[key] = res return self.cache[key] except TypeError: # happens if any of the parameters is a list return self.func(*args, **kwargs) def toList(value: Any, *, cast_tuple: bool = True) -> list: cls: tuple[type, ...] = (list, tuple) if cast_tuple else (list,) return list(value) if isinstance(value, cls) else [value] def transform_attrs(obj, keys, container, func, extras=None): """ Allows to apply one function to set of keys cheching if key is in container, also trasform ccs key to report lab keys. extras = Are extra params for func, it will be call like func(*[param1, param2]) obj = frag keys = [(reportlab, css), ... ] container = cssAttr """ cpextras = extras for reportlab_key, css in keys: extras = cpextras if extras is None: extras = [] elif not isinstance(extras, list): extras = [extras] if css in container: extras.insert(0, container[css]) setattr(obj, reportlab_key, func(*extras)) def copy_attrs(obj1, obj2, attrs): """ Allows copy a list of attributes from object2 to object1. Useful for copy ccs attributes to fragment. """ for attr in attrs: value = getattr(obj2, attr) if hasattr(obj2, attr) else None if value is None and isinstance(obj2, dict) and attr in obj2: value = obj2[attr] setattr(obj1, attr, value) def set_value(obj, attrs, value, *, do_copy=False): """Allows set the same value to a list of attributes.""" for attr in attrs: if do_copy: value = copy(value) setattr(obj, attr, value) @Memoized def getColor(value, default=None): """ Convert to color value. This returns a Color object instance from a text bit. """ if value is None: return None if isinstance(value, Color): return value value = str(value).strip().lower() if value in {"transparent", "none"}: return default if value in COLOR_BY_NAME: return COLOR_BY_NAME[value] if value.startswith("#") and len(value) == 4: value = "#" + value[1] + value[1] + value[2] + value[2] + value[3] + value[3] elif rgb_re.search(value): # e.g., value = "", go figure: r, g, b = (int(x) for x in rgb_re.search(value).groups()) value = f"#{r:02x}{g:02x}{b:02x}" else: # Shrug pass return toColor(value, default) # Calling the reportlab function def getBorderStyle(value, default=None): if value and (str(value).lower() not in ("none", "hidden")): return value return default MM: float = cm / 10.0 DPI96: float = 1.0 / 96.0 * inch ABSOLUTE_SIZE_TABLE: dict[str, float] = { "1": 50.0 / 100.0, "xx-small": 50.0 / 100.0, "x-small": 50.0 / 100.0, "2": 75.0 / 100.0, "small": 75.0 / 100.0, "3": 1.0, "medium": 1.0, "4": 125.0 / 100.0, "large": 125.0 / 100.0, "5": 150.0 / 100.0, "x-large": 150.0 / 100.0, "6": 175.0 / 100.0, "xx-large": 175.0 / 100.0, "7": 200.0 / 100.0, "xxx-large": 200.0 / 100.0, } RELATIVE_SIZE_TABLE: dict[str, float] = { "larger": 1.25, "smaller": 0.75, "+4": 200.0 / 100.0, "+3": 175.0 / 100.0, "+2": 150.0 / 100.0, "+1": 125.0 / 100.0, "-1": 75.0 / 100.0, "-2": 50.0 / 100.0, "-3": 25.0 / 100.0, } MIN_FONT_SIZE: float = 1.0 @Memoized def getSize( value: str | float | list | tuple, relative=0, base: int | None = None, default: float = 0.0, ) -> float: """ Converts strings to standard sizes. That is the function taking a string of CSS size ('12pt', '1cm' and so on) and converts it into a float in a standard unit (in our case, points). >>> getSize('12pt') 12.0 >>> getSize('1cm') 28.346456692913385 """ try: original = value if value is None: return relative if isinstance(value, float): return value if isinstance(value, int): return float(value) if isinstance(value, (tuple, list)): value = "".join(value) value = str(value).strip().lower().replace(",", ".") if value.endswith("cm"): return float(value[:-2].strip()) * cm if value.endswith("mm"): return float(value[:-2].strip()) * MM # 1MM = 0.1cm if value.endswith("in"): return float(value[:-2].strip()) * inch # 1pt == 1/72inch if value.endswith("pt"): return float(value[:-2].strip()) if value.endswith("pc"): return float(value[:-2].strip()) * 12.0 # 1pc == 12pt if value.endswith("px"): # XXX W3C says, use 96pdi # http://www.w3.org/TR/CSS21/syndata.html#length-units return float(value[:-2].strip()) * DPI96 if value in ("none", "0", "0.0", "auto"): return 0.0 if relative: if value.endswith("rem"): # XXX # 1rem = 1 * fontSize return float(value[:-3].strip()) * relative if value.endswith("em"): # XXX # 1em = 1 * fontSize return float(value[:-2].strip()) * relative if value.endswith("ex"): # XXX # 1ex = 1/2 fontSize return float(value[:-2].strip()) * (relative / 2.0) if value.endswith("%"): # 1% = (fontSize * 1) / 100 return (relative * float(value[:-1].strip())) / 100.0 if value in {"normal", "inherit"}: return relative if value in RELATIVE_SIZE_TABLE: if base: return max(MIN_FONT_SIZE, base * RELATIVE_SIZE_TABLE[value]) return max(MIN_FONT_SIZE, relative * RELATIVE_SIZE_TABLE[value]) if value in ABSOLUTE_SIZE_TABLE: if base: return max(MIN_FONT_SIZE, base * ABSOLUTE_SIZE_TABLE[value]) return max(MIN_FONT_SIZE, relative * ABSOLUTE_SIZE_TABLE[value]) return max(MIN_FONT_SIZE, relative * float(value)) try: value = float(value) except ValueError: log.warning("getSize: Not a float %r", value) return default # value = 0 return max(0, value) except Exception: log.warning("getSize %r %r", original, relative, exc_info=True) return default @Memoized def getCoords(x, y, w, h, pagesize): """ As a stupid programmer I like to use the upper left corner of the document as the 0,0 coords therefore we need to do some fancy calculations. """ # ~ print pagesize ax, ay = pagesize if x < 0: x = ax + x if y < 0: y = ay + y if w is not None and h is not None: if w <= 0: w = ax - x + w if h <= 0: h = ay - y + h return x, (ay - y - h), w, h return x, (ay - y) @Memoized def getBox(box, pagesize): """ Parse sizes by corners in the form: The last to values with negative values are interpreted as offsets form the right and lower border. """ box = str(box).split() if len(box) != 4: msg = "box not defined right way" raise RuntimeError(msg) x, y, w, h = (getSize(pos) for pos in box) return getCoords(x, y, w, h, pagesize) def getFrameDimensions( data, page_width: float, page_height: float ) -> tuple[float, float, float, float]: """ Calculate dimensions of a frame. Returns left, top, width and height of the frame in points. """ box = data.get("-pdf-frame-box", []) if len(box) == 4: return (getSize(box[0]), getSize(box[1]), getSize(box[2]), getSize(box[3])) top = getSize(data.get("top", 0)) left = getSize(data.get("left", 0)) bottom = getSize(data.get("bottom", 0)) right = getSize(data.get("right", 0)) if "height" in data: height = getSize(data["height"]) if "top" in data: top = getSize(data["top"]) bottom = page_height - (top + height) elif "bottom" in data: bottom = getSize(data["bottom"]) top = page_height - (bottom + height) if "width" in data: width = getSize(data["width"]) if "left" in data: left = getSize(data["left"]) right = page_width - (left + width) elif "right" in data: right = getSize(data["right"]) left = page_width - (right + width) top += getSize(data.get("margin-top", 0)) left += getSize(data.get("margin-left", 0)) bottom += getSize(data.get("margin-bottom", 0)) right += getSize(data.get("margin-right", 0)) width = page_width - (left + right) height = page_height - (top + bottom) return left, top, width, height @Memoized def getPos(position, pagesize): """Pair of coordinates.""" position = str(position).split() if len(position) != 2: msg = "position not defined right way" raise RuntimeError(msg) x, y = (getSize(pos) for pos in position) return getCoords(x, y, None, None, pagesize) def getBool(s): """Is it a boolean?.""" return str(s).lower() in ("y", "yes", "1", "true") def getFloat(s): with contextlib.suppress(Exception): return float(s) ALIGNMENTS = { "left": TA_LEFT, "center": TA_CENTER, "middle": TA_CENTER, "right": TA_RIGHT, "justify": TA_JUSTIFY, } def getAlign(value, default=TA_LEFT): return ALIGNMENTS.get(str(value).lower(), default) _rx_datauri = re.compile( "^data:(?P[a-z]+/[a-z]+);base64,(?P.*)$", re.M | re.DOTALL ) COLOR_BY_NAME = { "activeborder": Color(212, 208, 200), "activecaption": Color(10, 36, 106), "aliceblue": Color(0.941176, 0.972549, 1), "antiquewhite": Color(0.980392, 0.921569, 0.843137), "appworkspace": Color(128, 128, 128), "aqua": Color(0, 1, 1), "aquamarine": Color(0.498039, 1, 0.831373), "azure": Color(0.941176, 1, 1), "background": Color(58, 110, 165), "beige": Color(0.960784, 0.960784, 0.862745), "bisque": Color(1, 0.894118, 0.768627), "black": Color(0, 0, 0), "blanchedalmond": Color(1, 0.921569, 0.803922), "blue": Color(0, 0, 1), "blueviolet": Color(0.541176, 0.168627, 0.886275), "brown": Color(0.647059, 0.164706, 0.164706), "burlywood": Color(0.870588, 0.721569, 0.529412), "buttonface": Color(212, 208, 200), "buttonhighlight": Color(255, 255, 255), "buttonshadow": Color(128, 128, 128), "buttontext": Color(0, 0, 0), "cadetblue": Color(0.372549, 0.619608, 0.627451), "captiontext": Color(255, 255, 255), "chartreuse": Color(0.498039, 1, 0), "chocolate": Color(0.823529, 0.411765, 0.117647), "coral": Color(1, 0.498039, 0.313725), "cornflowerblue": Color(0.392157, 0.584314, 0.929412), "cornsilk": Color(1, 0.972549, 0.862745), "crimson": Color(0.862745, 0.078431, 0.235294), "cyan": Color(0, 1, 1), "darkblue": Color(0, 0, 0.545098), "darkcyan": Color(0, 0.545098, 0.545098), "darkgoldenrod": Color(0.721569, 0.52549, 0.043137), "darkgray": Color(0.662745, 0.662745, 0.662745), "darkgreen": Color(0, 0.392157, 0), "darkgrey": Color(0.662745, 0.662745, 0.662745), "darkkhaki": Color(0.741176, 0.717647, 0.419608), "darkmagenta": Color(0.545098, 0, 0.545098), "darkolivegreen": Color(0.333333, 0.419608, 0.184314), "darkorange": Color(1, 0.54902, 0), "darkorchid": Color(0.6, 0.196078, 0.8), "darkred": Color(0.545098, 0, 0), "darksalmon": Color(0.913725, 0.588235, 0.478431), "darkseagreen": Color(0.560784, 0.737255, 0.560784), "darkslateblue": Color(0.282353, 0.239216, 0.545098), "darkslategray": Color(0.184314, 0.309804, 0.309804), "darkslategrey": Color(0.184314, 0.309804, 0.309804), "darkturquoise": Color(0, 0.807843, 0.819608), "darkviolet": Color(0.580392, 0, 0.827451), "deeppink": Color(1, 0.078431, 0.576471), "deepskyblue": Color(0, 0.74902, 1), "dimgray": Color(0.411765, 0.411765, 0.411765), "dimgrey": Color(0.411765, 0.411765, 0.411765), "dodgerblue": Color(0.117647, 0.564706, 1), "firebrick": Color(0.698039, 0.133333, 0.133333), "floralwhite": Color(1, 0.980392, 0.941176), "forestgreen": Color(0.133333, 0.545098, 0.133333), "fuchsia": Color(1, 0, 1), "gainsboro": Color(0.862745, 0.862745, 0.862745), "ghostwhite": Color(0.972549, 0.972549, 1), "gold": Color(1, 0.843137, 0), "goldenrod": Color(0.854902, 0.647059, 0.12549), "gray": Color(0.501961, 0.501961, 0.501961), "graytext": Color(128, 128, 128), "green": Color(0, 0.501961, 0), "greenyellow": Color(0.678431, 1, 0.184314), "grey": Color(0.501961, 0.501961, 0.501961), "highlight": Color(10, 36, 106), "highlighttext": Color(255, 255, 255), "honeydew": Color(0.941176, 1, 0.941176), "hotpink": Color(1, 0.411765, 0.705882), "inactiveborder": Color(212, 208, 200), "inactivecaption": Color(128, 128, 128), "inactivecaptiontext": Color(212, 208, 200), "indianred": Color(0.803922, 0.360784, 0.360784), "indigo": Color(0.294118, 0, 0.509804), "infobackground": Color(255, 255, 225), "infotext": Color(0, 0, 0), "ivory": Color(1, 1, 0.941176), "khaki": Color(0.941176, 0.901961, 0.54902), "lavender": Color(0.901961, 0.901961, 0.980392), "lavenderblush": Color(1, 0.941176, 0.960784), "lawngreen": Color(0.486275, 0.988235, 0), "lemonchiffon": Color(1, 0.980392, 0.803922), "lightblue": Color(0.678431, 0.847059, 0.901961), "lightcoral": Color(0.941176, 0.501961, 0.501961), "lightcyan": Color(0.878431, 1, 1), "lightgoldenrodyellow": Color(0.980392, 0.980392, 0.823529), "lightgray": Color(0.827451, 0.827451, 0.827451), "lightgreen": Color(0.564706, 0.933333, 0.564706), "lightgrey": Color(0.827451, 0.827451, 0.827451), "lightpink": Color(1, 0.713725, 0.756863), "lightsalmon": Color(1, 0.627451, 0.478431), "lightseagreen": Color(0.12549, 0.698039, 0.666667), "lightskyblue": Color(0.529412, 0.807843, 0.980392), "lightslategray": Color(0.466667, 0.533333, 0.6), "lightslategrey": Color(0.466667, 0.533333, 0.6), "lightsteelblue": Color(0.690196, 0.768627, 0.870588), "lightyellow": Color(1, 1, 0.878431), "lime": Color(0, 1, 0), "limegreen": Color(0.196078, 0.803922, 0.196078), "linen": Color(0.980392, 0.941176, 0.901961), "magenta": Color(1, 0, 1), "maroon": Color(0.501961, 0, 0), "mediumaquamarine": Color(0.4, 0.803922, 0.666667), "mediumblue": Color(0, 0, 0.803922), "mediumorchid": Color(0.729412, 0.333333, 0.827451), "mediumpurple": Color(0.576471, 0.439216, 0.858824), "mediumseagreen": Color(0.235294, 0.701961, 0.443137), "mediumslateblue": Color(0.482353, 0.407843, 0.933333), "mediumspringgreen": Color(0, 0.980392, 0.603922), "mediumturquoise": Color(0.282353, 0.819608, 0.8), "mediumvioletred": Color(0.780392, 0.082353, 0.521569), "menu": Color(212, 208, 200), "menutext": Color(0, 0, 0), "midnightblue": Color(0.098039, 0.098039, 0.439216), "mintcream": Color(0.960784, 1, 0.980392), "mistyrose": Color(1, 0.894118, 0.882353), "moccasin": Color(1, 0.894118, 0.709804), "navajowhite": Color(1, 0.870588, 0.678431), "navy": Color(0, 0, 0.501961), "oldlace": Color(0.992157, 0.960784, 0.901961), "olive": Color(0.501961, 0.501961, 0), "olivedrab": Color(0.419608, 0.556863, 0.137255), "orange": Color(1, 0.647059, 0), "orangered": Color(1, 0.270588, 0), "orchid": Color(0.854902, 0.439216, 0.839216), "palegoldenrod": Color(0.933333, 0.909804, 0.666667), "palegreen": Color(0.596078, 0.984314, 0.596078), "paleturquoise": Color(0.686275, 0.933333, 0.933333), "palevioletred": Color(0.858824, 0.439216, 0.576471), "papayawhip": Color(1, 0.937255, 0.835294), "peachpuff": Color(1, 0.854902, 0.72549), "peru": Color(0.803922, 0.521569, 0.247059), "pink": Color(1, 0.752941, 0.796078), "plum": Color(0.866667, 0.627451, 0.866667), "powderblue": Color(0.690196, 0.878431, 0.901961), "purple": Color(0.501961, 0, 0.501961), "red": Color(1, 0, 0), "rosybrown": Color(0.737255, 0.560784, 0.560784), "royalblue": Color(0.254902, 0.411765, 0.882353), "saddlebrown": Color(0.545098, 0.270588, 0.07451), "salmon": Color(0.980392, 0.501961, 0.447059), "sandybrown": Color(0.956863, 0.643137, 0.376471), "scrollbar": Color(212, 208, 200), "seagreen": Color(0.180392, 0.545098, 0.341176), "seashell": Color(1, 0.960784, 0.933333), "sienna": Color(0.627451, 0.321569, 0.176471), "silver": Color(0.752941, 0.752941, 0.752941), "skyblue": Color(0.529412, 0.807843, 0.921569), "slateblue": Color(0.415686, 0.352941, 0.803922), "slategray": Color(0.439216, 0.501961, 0.564706), "slategrey": Color(0.439216, 0.501961, 0.564706), "snow": Color(1, 0.980392, 0.980392), "springgreen": Color(0, 1, 0.498039), "steelblue": Color(0.27451, 0.509804, 0.705882), "tan": Color(0.823529, 0.705882, 0.54902), "teal": Color(0, 0.501961, 0.501961), "thistle": Color(0.847059, 0.74902, 0.847059), "threeddarkshadow": Color(64, 64, 64), "threedface": Color(212, 208, 200), "threedhighlight": Color(255, 255, 255), "threedlightshadow": Color(212, 208, 200), "threedshadow": Color(128, 128, 128), "tomato": Color(1, 0.388235, 0.278431), "turquoise": Color(0.25098, 0.878431, 0.815686), "violet": Color(0.933333, 0.509804, 0.933333), "wheat": Color(0.960784, 0.870588, 0.701961), "white": Color(1, 1, 1), "whitesmoke": Color(0.960784, 0.960784, 0.960784), "window": Color(255, 255, 255), "windowframe": Color(0, 0, 0), "windowtext": Color(0, 0, 0), "yellow": Color(1, 1, 0), "yellowgreen": Color(0.603922, 0.803922, 0.196078), } def get_default_asian_font(): lower_font_list = [] upper_font_list = [] font_dict = copy(reportlab.pdfbase._cidfontdata.defaultUnicodeEncodings) fonts = font_dict.keys() for font in fonts: upper_font_list.append(font) lower_font_list.append(font.lower()) return {lower_font_list[i]: upper_font_list[i] for i in range(len(lower_font_list))} def set_asian_fonts(fontname): font_dict = copy(reportlab.pdfbase._cidfontdata.defaultUnicodeEncodings) fonts = font_dict.keys() if fontname in fonts: pdfmetrics.registerFont(UnicodeCIDFont(fontname)) def detect_language(name): asian_language_list = xhtml2pdf.default.DEFAULT_LANGUAGE_LIST if name in asian_language_list: return name return None def arabic_format(text, language): # Note: right now all of the languages are treated the same way. # But maybe in the future we have to for example implement something # for "hebrew" that isn't used in "arabic" if detect_language(language) in ( "arabic", "hebrew", "persian", "urdu", "pashto", "sindhi", ): ar = arabic_reshaper.reshape(text) return get_display(ar) return None def frag_text_language_check(context, frag_text): if hasattr(context, "language"): language = context.__getattribute__("language") detect_language_result = arabic_format(frag_text, language) if detect_language_result: return detect_language_result return None return None class ImageWarning(Exception): # noqa: N818 pass