1005 lines
36 KiB
Python
1005 lines
36 KiB
Python
# 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.
|
|
|
|
# ruff: noqa: N802, N803
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import copy
|
|
import logging
|
|
import sys
|
|
from hashlib import md5
|
|
from html import escape as html_escape
|
|
from io import BytesIO, StringIO
|
|
from typing import TYPE_CHECKING, ClassVar, Iterator
|
|
from uuid import uuid4
|
|
|
|
from PIL import Image as PILImage
|
|
from PIL import UnidentifiedImageError
|
|
from PIL.Image import Image
|
|
from reportlab.lib.enums import TA_RIGHT
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.utils import LazyImageReader, flatten, haveImages, open_for_read
|
|
from reportlab.pdfbase import pdfform
|
|
from reportlab.platypus.doctemplate import (
|
|
BaseDocTemplate,
|
|
IndexingFlowable,
|
|
PageTemplate,
|
|
)
|
|
from reportlab.platypus.flowables import (
|
|
CondPageBreak,
|
|
Flowable,
|
|
KeepInFrame,
|
|
ParagraphAndImage,
|
|
)
|
|
from reportlab.platypus.tableofcontents import TableOfContents
|
|
from reportlab.platypus.tables import Table, TableStyle
|
|
from reportlab.rl_config import register_reset
|
|
|
|
from xhtml2pdf.builders.watermarks import WaterMarks
|
|
from xhtml2pdf.files import pisaFileObject, pisaTempFile
|
|
from xhtml2pdf.reportlab_paragraph import Paragraph
|
|
from xhtml2pdf.util import ImageWarning, getBorderStyle
|
|
|
|
if TYPE_CHECKING:
|
|
from reportlab.graphics.shapes import Drawing
|
|
from reportlab.pdfgen.canvas import Canvas
|
|
|
|
|
|
try:
|
|
from reportlab.graphics import renderPM
|
|
from svglib.svglib import svg2rlg
|
|
except ImportError:
|
|
svg2rlg = None
|
|
renderPM = None
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
MAX_IMAGE_RATIO: float = 0.95
|
|
PRODUCER: str = "xhtml2pdf <https://github.com/xhtml2pdf/xhtml2pdf/>"
|
|
|
|
|
|
class PTCycle(list):
|
|
def __init__(self) -> None:
|
|
self._restart: int = 0
|
|
self._idx: int = 0
|
|
super().__init__()
|
|
|
|
def cyclicIterator(self) -> Iterator:
|
|
while 1:
|
|
yield self[self._idx]
|
|
self._idx += 1
|
|
if self._idx >= len(self):
|
|
self._idx = self._restart
|
|
|
|
|
|
class PmlMaxHeightMixIn:
|
|
def setMaxHeight(self, availHeight: int) -> int:
|
|
self.availHeightValue: int = availHeight
|
|
if availHeight < 70000 and hasattr(self, "canv"):
|
|
if not hasattr(self.canv, "maxAvailHeightValue"):
|
|
self.canv.maxAvailHeightValue = 0
|
|
self.availHeightValue = self.canv.maxAvailHeightValue = max(
|
|
availHeight, self.canv.maxAvailHeightValue
|
|
)
|
|
return self.availHeightValue
|
|
|
|
def getMaxHeight(self) -> int:
|
|
return self.availHeightValue if hasattr(self, "availHeightValue") else 0
|
|
|
|
|
|
class PmlBaseDoc(BaseDocTemplate):
|
|
"""We use our own document template to get access to the canvas and set some information once."""
|
|
|
|
def beforePage(self) -> None:
|
|
self.canv._doc.info.producer = PRODUCER
|
|
|
|
"""
|
|
# Convert to ASCII because there is a Bug in Reportlab not
|
|
# supporting other than ASCII. Send to list on 23.1.2007
|
|
author = toString(self.pml_data.get("author", "")).encode("ascii","ignore")
|
|
subject = toString(self.pml_data.get("subject", "")).encode("ascii","ignore")
|
|
title = toString(self.pml_data.get("title", "")).encode("ascii","ignore")
|
|
# print repr((author,title,subject))
|
|
self.canv.setAuthor(author)
|
|
self.canv.setSubject(subject)
|
|
self.canv.setTitle(title)
|
|
if self.pml_data.get("fullscreen", 0):
|
|
self.canv.showFullScreen0()
|
|
if self.pml_data.get("showoutline", 0):
|
|
self.canv.showOutline()
|
|
if self.pml_data.get("duration", None) is not None:
|
|
self.canv.setPageDuration(self.pml_data["duration"])
|
|
"""
|
|
|
|
def afterFlowable(self, flowable: Flowable) -> None:
|
|
# Does the flowable contain fragments?
|
|
if getattr(flowable, "outline", False):
|
|
self.notify(
|
|
"TOCEntry",
|
|
(
|
|
flowable.outlineLevel,
|
|
html_escape(copy.deepcopy(flowable.text), quote=True),
|
|
self.page,
|
|
),
|
|
)
|
|
|
|
def handle_nextPageTemplate(self, pt: str | int | list | tuple) -> None:
|
|
"""If pt has also templates for even and odd page convert it to list."""
|
|
has_left_template: bool = self._has_template_for_name(f"{pt}_left")
|
|
has_right_template: bool = self._has_template_for_name(f"{pt}_right")
|
|
|
|
if has_left_template and has_right_template:
|
|
pt = [f"{pt}_left", f"{pt}_right"]
|
|
|
|
"""On endPage change to the page template with name or index pt"""
|
|
if isinstance(pt, str):
|
|
if hasattr(self, "_nextPageTemplateCycle"):
|
|
del self._nextPageTemplateCycle
|
|
for t in self.pageTemplates:
|
|
if t.id == pt:
|
|
self._nextPageTemplateIndex: int = self.pageTemplates.index(t)
|
|
return
|
|
msg = f"can't find template('{pt}')"
|
|
raise ValueError(msg)
|
|
if isinstance(pt, int):
|
|
if hasattr(self, "_nextPageTemplateCycle"):
|
|
del self._nextPageTemplateCycle
|
|
self._nextPageTemplateIndex = pt
|
|
elif isinstance(pt, (list, tuple)):
|
|
# used for alternating left/right pages
|
|
# collect the refs to the template objects, complain if any are bad
|
|
c: PTCycle = PTCycle()
|
|
for ptn in pt:
|
|
# special case name used to short circuit the iteration
|
|
if ptn == "*":
|
|
c._restart = len(c)
|
|
continue
|
|
for t in self.pageTemplates:
|
|
sys.exit()
|
|
if t.id == ptn.strip():
|
|
c.append(t)
|
|
break
|
|
if not c:
|
|
msg = "No valid page templates in cycle"
|
|
raise ValueError(msg)
|
|
if c._restart > len(c):
|
|
msg = "Invalid cycle restart position"
|
|
raise ValueError(msg)
|
|
|
|
# ensure we start on the first one$
|
|
self._nextPageTemplateCycle: PageTemplate = c.cyclicIterator()
|
|
else:
|
|
msg = "Argument pt should be string or integer or list"
|
|
raise TypeError(msg)
|
|
|
|
def _has_template_for_name(self, name: str) -> bool:
|
|
return any(template.id == name.strip() for template in self.pageTemplates)
|
|
|
|
|
|
class PmlPageTemplate(PageTemplate):
|
|
PORTRAIT: str = "portrait"
|
|
LANDSCAPE: str = "landscape"
|
|
# by default portrait
|
|
pageorientation: str = PORTRAIT
|
|
|
|
def __init__(self, **kw) -> None:
|
|
self.pisaStaticList: list = []
|
|
self.pisaBackgroundList: list[tuple] = []
|
|
self.pisaBackground = None
|
|
super().__init__(**kw)
|
|
self._page_count: int = 0
|
|
self._first_flow: bool = True
|
|
|
|
# Background Image
|
|
self.img = None
|
|
self.ph: int = 0
|
|
self.h: int = 0
|
|
self.w: int = 0
|
|
|
|
self.backgroundids: list[int] = []
|
|
|
|
def isFirstFlow(self, canvas: Canvas) -> bool:
|
|
if self._first_flow:
|
|
if canvas.getPageNumber() <= self._page_count:
|
|
self._first_flow = False
|
|
else:
|
|
self._page_count = canvas.getPageNumber()
|
|
canvas._doctemplate._page_count = canvas.getPageNumber()
|
|
return self._first_flow
|
|
|
|
def isPortrait(self) -> bool:
|
|
return self.pageorientation == self.PORTRAIT
|
|
|
|
def isLandscape(self) -> bool:
|
|
return self.pageorientation == self.LANDSCAPE
|
|
|
|
def beforeDrawPage(self, canvas: Canvas, doc):
|
|
canvas.saveState()
|
|
try:
|
|
if doc.pageTemplate.id not in self.backgroundids:
|
|
pisaBackground = None
|
|
if (
|
|
hasattr(self, "pisaBackground")
|
|
and self.pisaBackground
|
|
and (not self.pisaBackground.notFound())
|
|
):
|
|
if self.pisaBackground.getMimeType().startswith("image/"):
|
|
pisaBackground = WaterMarks.generate_pdf_background(
|
|
self.pisaBackground,
|
|
self.pagesize,
|
|
is_portrait=self.isPortrait(),
|
|
context=self.backgroundContext,
|
|
)
|
|
else:
|
|
pisaBackground = self.pisaBackground
|
|
self.backgroundids.append(doc.pageTemplate.id)
|
|
if pisaBackground:
|
|
self.pisaBackgroundList.append(
|
|
(canvas.getPageNumber(), pisaBackground, self.backgroundContext)
|
|
)
|
|
|
|
def pageNumbering(objList):
|
|
for obj in flatten(objList):
|
|
if isinstance(obj, PmlParagraph):
|
|
for frag in obj.frags:
|
|
if frag.pageNumber:
|
|
frag.text = str(pagenumber)
|
|
elif frag.pageCount:
|
|
frag.text = str(canvas._doctemplate._page_count)
|
|
|
|
elif isinstance(obj, PmlTable):
|
|
# Flatten the cells ([[1,2], [3,4]] becomes [1,2,3,4])
|
|
flat_cells = [
|
|
item for sublist in obj._cellvalues for item in sublist
|
|
]
|
|
pageNumbering(flat_cells)
|
|
|
|
try:
|
|
# Paint static frames
|
|
pagenumber = canvas.getPageNumber()
|
|
if pagenumber > self._page_count:
|
|
self._page_count = canvas.getPageNumber()
|
|
canvas._doctemplate._page_count = canvas.getPageNumber()
|
|
|
|
for frame in self.pisaStaticList:
|
|
frame_copy = copy.deepcopy(frame)
|
|
story = frame_copy.pisaStaticStory
|
|
pageNumbering(story)
|
|
|
|
frame_copy.addFromList(story, canvas)
|
|
|
|
except Exception: # TODO: Kill this!
|
|
log.debug("PmlPageTemplate", exc_info=True)
|
|
finally:
|
|
canvas.restoreState()
|
|
|
|
|
|
_ctr: int = 1
|
|
|
|
|
|
class PmlImageReader: # TODO We need a factory here, returning either a class for java or a class for PIL
|
|
"""Wraps up either PIL or Java to get data from bitmaps."""
|
|
|
|
_cache: ClassVar[dict] = {}
|
|
# Experimental features, disabled by default
|
|
use_cache: bool = False
|
|
use_lazy_loader: bool = False
|
|
process_internal_files: bool = False
|
|
|
|
def __init__(self, fileName: PmlImage | Image | str) -> None:
|
|
if isinstance(fileName, PmlImage):
|
|
self.__dict__ = fileName.__dict__ # borgize
|
|
return
|
|
# start wih lots of null private fields, to be populated by
|
|
# the relevant engine.
|
|
self.fileName: PmlImage | Image | str = fileName or f"PILIMAGE_{id(self)}"
|
|
self._image: Image = None
|
|
self._width: int | None = None
|
|
self._height: int | None = None
|
|
self._transparent = None
|
|
self._data: bytes | str | None = None
|
|
self._dataA: PmlImageReader | None = None
|
|
self.fp: BytesIO | StringIO | None = None
|
|
if Image and isinstance(fileName, Image):
|
|
self._image = fileName
|
|
self.fp = getattr(fileName, "fp", None)
|
|
else:
|
|
try:
|
|
self.fp = open_for_read(fileName, "b")
|
|
if self.process_internal_files and isinstance(self.fp, StringIO):
|
|
data: str = self.fp.read()
|
|
with contextlib.suppress(Exception):
|
|
self.fp.close()
|
|
if self.use_cache:
|
|
if not self._cache:
|
|
register_reset(self._cache.clear)
|
|
cache_key = md5(data.encode("utf8")).digest()
|
|
data = self._cache.setdefault(cache_key, data)
|
|
self.fp = StringIO(data)
|
|
elif self.use_lazy_loader and isinstance(fileName, str):
|
|
# try Ralf Schmitt's re-opening technique of avoiding too many open files
|
|
self.fp.close()
|
|
del self.fp # will become a property in the next statement
|
|
self.__class__ = LazyImageReader
|
|
if haveImages:
|
|
# detect which library we are using and open the image
|
|
if not self._image:
|
|
self._image = self._read_image(self.fp)
|
|
if getattr(self._image, "format", None) == "JPEG":
|
|
self.jpeg_fh = self._jpeg_fh
|
|
else:
|
|
from reportlab.pdfbase.pdfutils import readJPEGInfo
|
|
|
|
try:
|
|
self._width, self._height, c = readJPEGInfo(self.fp)
|
|
except Exception as e:
|
|
msg = (
|
|
"Imaging Library not available, unable to import bitmaps"
|
|
" only jpegs"
|
|
)
|
|
raise ImageWarning(msg) from e
|
|
self.jpeg_fh = self._jpeg_fh
|
|
self._data = self.fp.read()
|
|
self.fp.seek(0)
|
|
# Catch all errors that are known and don't need the stack trace
|
|
except UnidentifiedImageError as e:
|
|
msg = "Cannot identify image file"
|
|
raise ImageWarning(msg) from e
|
|
|
|
@staticmethod
|
|
def _read_image(fp) -> Image:
|
|
if sys.platform[:4] == "java":
|
|
from java.io import ByteArrayInputStream
|
|
from javax.imageio import ImageIO
|
|
|
|
input_stream = ByteArrayInputStream(fp.read())
|
|
return ImageIO.read(input_stream)
|
|
return PILImage.open(fp)
|
|
|
|
def _jpeg_fh(self) -> BytesIO | StringIO | None:
|
|
fp = self.fp
|
|
if isinstance(fp, (BytesIO, StringIO)):
|
|
fp.seek(0)
|
|
return fp
|
|
|
|
def jpeg_fh(self) -> BytesIO | StringIO | None: # noqa: PLR6301
|
|
"""Might be replaced with _jpeg_fh in some cases"""
|
|
return None
|
|
|
|
def getSize(self) -> tuple[int, int]:
|
|
if self._width is None or self._height is None:
|
|
if sys.platform[:4] == "java":
|
|
self._width = self._image.getWidth()
|
|
self._height = self._image.getHeight()
|
|
else:
|
|
self._width, self._height = self._image.size
|
|
if TYPE_CHECKING:
|
|
assert self._width is not None and self._height is not None
|
|
return self._width, self._height
|
|
|
|
def getRGBData(self) -> bytes | str:
|
|
"""Return byte array of RGB data as string."""
|
|
if self._data is None:
|
|
self._dataA = None
|
|
if sys.platform[:4] == "java":
|
|
import jarray # TODO: Move to top.
|
|
from java.awt.image import PixelGrabber
|
|
|
|
width, height = self.getSize()
|
|
buffer = jarray.zeros(width * height, "i")
|
|
pg: PixelGrabber = PixelGrabber(
|
|
self._image, 0, 0, width, height, buffer, 0, width
|
|
)
|
|
pg.grabPixels()
|
|
# there must be a way to do this with a cast not a byte-level loop,
|
|
# I just haven't found it yet...
|
|
pixels: list[str] = []
|
|
a = pixels.append
|
|
for rgb in buffer:
|
|
a(chr((rgb >> 16) & 0xFF))
|
|
a(chr((rgb >> 8) & 0xFF))
|
|
a(chr(rgb & 0xFF))
|
|
self._data = "".join(pixels)
|
|
self.mode = "RGB"
|
|
else:
|
|
im = self._image
|
|
mode = self.mode = im.mode
|
|
if mode == "RGBA":
|
|
im.load()
|
|
self._dataA = PmlImageReader(im.split()[3])
|
|
im = im.convert("RGB")
|
|
self.mode = "RGB"
|
|
elif mode not in ("L", "RGB", "CMYK"):
|
|
im = im.convert("RGB")
|
|
self.mode = "RGB"
|
|
self._data = im.tobytes() if hasattr(im, "tobytes") else im.tostring()
|
|
return self._data
|
|
|
|
def getImageData(self):
|
|
width, height = self.getSize()
|
|
return width, height, self.getRGBData()
|
|
|
|
def getTransparent(self):
|
|
if sys.platform[:4] == "java":
|
|
return None
|
|
if "transparency" in self._image.info:
|
|
transparency = self._image.info["transparency"] * 3
|
|
palette = self._image.palette
|
|
if hasattr(palette, "palette"):
|
|
palette = palette.palette
|
|
elif hasattr(palette, "data"):
|
|
palette = palette.data
|
|
else:
|
|
return None
|
|
|
|
# 8-bit PNGs could give an empty string as transparency value, so
|
|
# we have to be careful here.
|
|
try:
|
|
return list(palette[transparency : transparency + 3])
|
|
except Exception as e:
|
|
log.debug(str(e), exc_info=e)
|
|
return None
|
|
else:
|
|
return None
|
|
|
|
def __str__(self) -> str:
|
|
if isinstance(self.fileName, (PmlImage, Image, BytesIO)):
|
|
fn = self.fileName.read() or id(self)
|
|
return f"PmlImageObject_{hash(fn)}"
|
|
return str(self.fileName or id(self))
|
|
|
|
|
|
class PmlImage(Flowable, PmlMaxHeightMixIn):
|
|
def __init__(
|
|
self,
|
|
data: pisaFileObject | pisaTempFile | bytes,
|
|
src: str | None = None,
|
|
width: int | None = None,
|
|
height: int | None = None,
|
|
mask: str = "auto",
|
|
mimetype: str | None = None,
|
|
**kw: dict,
|
|
) -> None:
|
|
self.kw: dict = kw
|
|
self.hAlign: str = "CENTER"
|
|
self._mask: str = mask
|
|
self._imgdata: bytes = b""
|
|
if isinstance(data, bytes):
|
|
self._imgdata = data
|
|
elif isinstance(data, pisaTempFile):
|
|
self._imgdata = data.getvalue()
|
|
elif isinstance(data, pisaFileObject):
|
|
self._imgdata = data.getData() or b""
|
|
self.src: str | None = src
|
|
# print "###", repr(data)
|
|
self.mimetype: str | None = mimetype
|
|
|
|
# Resolve size
|
|
drawing = self.getDrawing()
|
|
self.imageWidth: float = 0.0
|
|
self.imageHeight: float = 0.0
|
|
if drawing:
|
|
_, _, self.imageWidth, self.imageHeight = drawing.getBounds() or (
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
)
|
|
else:
|
|
img = self.getImage()
|
|
if img:
|
|
self.imageWidth, self.imageHeight = img.getSize()
|
|
|
|
self.drawWidth: float = width or self.imageWidth
|
|
self.drawHeight: float = height or self.imageHeight
|
|
|
|
def wrap(self, availWidth, availHeight):
|
|
"""
|
|
Resize the image if necessary.
|
|
|
|
This can be called more than once! Do not overwrite important data like drawWidth.
|
|
"""
|
|
availHeight = self.setMaxHeight(availHeight)
|
|
# print "image wrap", id(self), availWidth, availHeight, self.drawWidth, self.drawHeight
|
|
width = min(self.drawWidth, availWidth)
|
|
wfactor = float(width) / self.drawWidth
|
|
height = min(self.drawHeight, availHeight * MAX_IMAGE_RATIO)
|
|
hfactor = float(height) / self.drawHeight
|
|
factor = min(wfactor, hfactor)
|
|
self.dWidth = self.drawWidth * factor
|
|
self.dHeight = self.drawHeight * factor
|
|
# print "imgage result", factor, self.dWidth, self.dHeight
|
|
return self.dWidth, self.dHeight
|
|
|
|
def getDrawing(
|
|
self, width: float | None = None, height: float | None = None
|
|
) -> Drawing | None:
|
|
"""If this image is a vector image and the library is available, returns a ReportLab Drawing."""
|
|
if svg2rlg:
|
|
try:
|
|
drawing = svg2rlg(BytesIO(self._imgdata))
|
|
except Exception:
|
|
return None
|
|
if drawing:
|
|
# Apply size
|
|
scale_x = 1
|
|
scale_y = 1
|
|
try:
|
|
if getattr(self, "drawWidth", None) is not None:
|
|
if width is None:
|
|
width = self.drawWidth
|
|
scale_x = width / drawing.width
|
|
if getattr(self, "drawHeight", None) is not None:
|
|
if height is None:
|
|
height = self.drawHeight
|
|
scale_y = height / drawing.height
|
|
if scale_x != 1 or scale_y != 1:
|
|
drawing.scale(scale_x, scale_y)
|
|
except ZeroDivisionError:
|
|
log.warning(
|
|
"SVG drawing could not be resized: %r",
|
|
self.src or self._imgdata[:50],
|
|
)
|
|
return drawing
|
|
return None
|
|
|
|
def getDrawingRaster(self) -> BytesIO | None:
|
|
"""If this image is a vector image and the libraries are available, returns a PNG raster."""
|
|
if svg2rlg and renderPM:
|
|
svg: Drawing = self.getDrawing()
|
|
if svg:
|
|
imgdata = BytesIO()
|
|
renderPM.drawToFile(svg, imgdata, fmt="PNG")
|
|
return imgdata
|
|
return None
|
|
|
|
def getImage(self) -> PmlImageReader:
|
|
"""Return a raster image."""
|
|
vectorRaster = self.getDrawingRaster()
|
|
imgdata = vectorRaster or BytesIO(self._imgdata)
|
|
return PmlImageReader(imgdata)
|
|
|
|
def draw(self) -> None:
|
|
# TODO this code should work, but untested
|
|
# drawing = self.getDrawing(self.dWidth, self.dHeight)
|
|
# if drawing and renderPDF:
|
|
# renderPDF.draw(drawing, self.canv, 0, 0)
|
|
# else:
|
|
img = self.getImage()
|
|
self.canv.drawImage(img, 0, 0, self.dWidth, self.dHeight, mask=self._mask)
|
|
|
|
def identity(self, maxLen=None):
|
|
return Flowable.identity(self, maxLen)
|
|
|
|
|
|
class PmlParagraphAndImage(ParagraphAndImage, PmlMaxHeightMixIn):
|
|
def wrap(self, availWidth, availHeight):
|
|
self.I.canv = self.canv
|
|
result = ParagraphAndImage.wrap(self, availWidth, availHeight)
|
|
del self.I.canv
|
|
return result
|
|
|
|
def split(self, availWidth, availHeight):
|
|
# print "# split", id(self)
|
|
if not hasattr(self, "wI"):
|
|
self.wI, self.hI = self.I.wrap(
|
|
availWidth, availHeight
|
|
) # drawWidth, self.I.drawHeight
|
|
return ParagraphAndImage.split(self, availWidth, availHeight)
|
|
|
|
|
|
class PmlParagraph(Paragraph, PmlMaxHeightMixIn):
|
|
def _calcImageMaxSizes(self, availWidth, availHeight):
|
|
self.hasImages = False
|
|
for frag in self.frags:
|
|
if hasattr(frag, "cbDefn") and frag.cbDefn.kind == "img":
|
|
img = frag.cbDefn
|
|
if img.width > 0 and img.height > 0:
|
|
self.hasImages = True
|
|
width = min(img.width, availWidth)
|
|
wfactor = float(width) / img.width
|
|
height = min(
|
|
img.height, availHeight * MAX_IMAGE_RATIO
|
|
) # XXX 99% because 100% do not work...
|
|
hfactor = float(height) / img.height
|
|
factor = min(wfactor, hfactor)
|
|
img.height *= factor
|
|
img.width *= factor
|
|
|
|
def wrap(self, availWidth, availHeight):
|
|
availHeight = self.setMaxHeight(availHeight)
|
|
|
|
style = self.style
|
|
|
|
self.deltaWidth = (
|
|
style.paddingLeft
|
|
+ style.paddingRight
|
|
+ style.borderLeftWidth
|
|
+ style.borderRightWidth
|
|
)
|
|
self.deltaHeight = (
|
|
style.paddingTop
|
|
+ style.paddingBottom
|
|
+ style.borderTopWidth
|
|
+ style.borderBottomWidth
|
|
)
|
|
|
|
# reduce the available width & height by the padding so the wrapping
|
|
# will use the correct size
|
|
availWidth -= self.deltaWidth
|
|
availHeight -= self.deltaHeight
|
|
|
|
# Modify maxium image sizes
|
|
self._calcImageMaxSizes(availWidth, availHeight)
|
|
|
|
# call the base class to do wrapping and calculate the size
|
|
Paragraph.wrap(self, availWidth, availHeight)
|
|
|
|
# self.height = max(1, self.height)
|
|
# self.width = max(1, self.width)
|
|
|
|
# increase the calculated size by the padding
|
|
self.width = self.width + self.deltaWidth
|
|
self.height = self.height + self.deltaHeight
|
|
|
|
return self.width, self.height
|
|
|
|
def split(self, availWidth, availHeight):
|
|
if len(self.frags) <= 0:
|
|
return []
|
|
|
|
# the split information is all inside self.blPara
|
|
if not hasattr(self, "deltaWidth"):
|
|
self.wrap(availWidth, availHeight)
|
|
|
|
availWidth -= self.deltaWidth
|
|
availHeight -= self.deltaHeight
|
|
|
|
return Paragraph.split(self, availWidth, availHeight)
|
|
|
|
def draw(self):
|
|
# Create outline
|
|
if getattr(self, "outline", False):
|
|
# Check level and add all levels
|
|
last = getattr(self.canv, "outlineLast", -1) + 1
|
|
while last < self.outlineLevel:
|
|
# print "(OUTLINE", last, self.text
|
|
key = uuid4().hex
|
|
self.canv.bookmarkPage(key)
|
|
self.canv.addOutlineEntry(self.text, key, last, not self.outlineOpen)
|
|
last += 1
|
|
self.canv.outlineLast = self.outlineLevel
|
|
|
|
key = uuid4().hex
|
|
|
|
self.canv.bookmarkPage(key)
|
|
self.canv.addOutlineEntry(
|
|
self.text, key, self.outlineLevel, not self.outlineOpen
|
|
)
|
|
last += 1
|
|
|
|
# Draw the background and borders here before passing control on to
|
|
# ReportLab. This is because ReportLab can't handle the individual
|
|
# components of the border independently. This will also let us
|
|
# support more border styles eventually.
|
|
canvas = self.canv
|
|
style = self.style
|
|
bg = style.backColor
|
|
leftIndent = style.leftIndent
|
|
bp = 0 # style.borderPadding
|
|
|
|
x = leftIndent - bp
|
|
y = -bp
|
|
w = self.width - (leftIndent + style.rightIndent) + 2 * bp
|
|
h = self.height + 2 * bp
|
|
|
|
if bg:
|
|
# draw a filled rectangle (with no stroke) using bg color
|
|
canvas.saveState()
|
|
canvas.setFillColor(bg)
|
|
canvas.rect(x, y, w, h, fill=1, stroke=0)
|
|
canvas.restoreState()
|
|
|
|
# we need to hide the bg color (if any) so Paragraph won't try to draw it again
|
|
style.backColor = None
|
|
|
|
# offset the origin to compensate for the padding
|
|
canvas.saveState()
|
|
canvas.translate(
|
|
(style.paddingLeft + style.borderLeftWidth),
|
|
-1 * (style.paddingTop + style.borderTopWidth),
|
|
) # + (style.leading / 4)))
|
|
|
|
# Call the base class draw method to finish up
|
|
Paragraph.draw(self)
|
|
canvas.restoreState()
|
|
|
|
# Reset color because we need it again if we run 2-PASS like we
|
|
# do when using TOC
|
|
style.backColor = bg
|
|
|
|
canvas.saveState()
|
|
|
|
def _drawBorderLine(bstyle, width, color, x1, y1, x2, y2):
|
|
# We need width and border style to be able to draw a border
|
|
if width and getBorderStyle(bstyle):
|
|
# If no color for border is given, the text color is used (like defined by W3C)
|
|
if color is None:
|
|
color = style.textColor
|
|
# print "Border", bstyle, width, color
|
|
if color is not None:
|
|
canvas.setStrokeColor(color)
|
|
canvas.setLineWidth(width)
|
|
canvas.line(x1, y1, x2, y2)
|
|
|
|
_drawBorderLine(
|
|
style.borderLeftStyle,
|
|
style.borderLeftWidth,
|
|
style.borderLeftColor,
|
|
x,
|
|
y,
|
|
x,
|
|
y + h,
|
|
)
|
|
_drawBorderLine(
|
|
style.borderRightStyle,
|
|
style.borderRightWidth,
|
|
style.borderRightColor,
|
|
x + w,
|
|
y,
|
|
x + w,
|
|
y + h,
|
|
)
|
|
_drawBorderLine(
|
|
style.borderTopStyle,
|
|
style.borderTopWidth,
|
|
style.borderTopColor,
|
|
x,
|
|
y + h,
|
|
x + w,
|
|
y + h,
|
|
)
|
|
_drawBorderLine(
|
|
style.borderBottomStyle,
|
|
style.borderBottomWidth,
|
|
style.borderBottomColor,
|
|
x,
|
|
y,
|
|
x + w,
|
|
y,
|
|
)
|
|
|
|
canvas.restoreState()
|
|
|
|
|
|
class PmlKeepInFrame(KeepInFrame, PmlMaxHeightMixIn):
|
|
def wrap(self, availWidth, availHeight):
|
|
availWidth = max(availWidth, 1.0)
|
|
availHeight = max(availHeight, 1.0)
|
|
self.maxWidth = availWidth
|
|
self.maxHeight = self.setMaxHeight(availHeight)
|
|
return KeepInFrame.wrap(self, availWidth, availHeight)
|
|
|
|
|
|
class PmlTable(Table, PmlMaxHeightMixIn):
|
|
@staticmethod
|
|
def _normWidth(w, maxw):
|
|
"""Normalize width when using percentages."""
|
|
if isinstance(w, str):
|
|
w = (maxw / 100.0) * float(w[:-1])
|
|
elif (w is None) or (w == "*"):
|
|
w = maxw
|
|
return min(w, maxw)
|
|
|
|
def _listCellGeom(self, V, w, s, W=None, H=None, aH=72000):
|
|
# print "#", self.availHeightValue
|
|
if aH == 72000:
|
|
aH = self.getMaxHeight() or aH
|
|
return Table._listCellGeom(self, V, w, s, W=W, H=H, aH=aH)
|
|
|
|
def wrap(self, availWidth, availHeight):
|
|
self.setMaxHeight(availHeight)
|
|
|
|
# Strange bug, sometime the totalWidth is not set !?
|
|
if not hasattr(self, "totalWidth"):
|
|
self.totalWidth = availWidth
|
|
|
|
# Prepare values
|
|
totalWidth = self._normWidth(self.totalWidth, availWidth)
|
|
remainingWidth = totalWidth
|
|
remainingCols = 0
|
|
newColWidths = self._colWidths
|
|
|
|
# Calculate widths that are fix
|
|
# IMPORTANT!!! We can not substitute the private value
|
|
# self._colWidths therefore we have to modify list in place
|
|
for i, colWidth in enumerate(newColWidths):
|
|
if colWidth is not None:
|
|
newColWidth = self._normWidth(colWidth, totalWidth)
|
|
remainingWidth -= newColWidth
|
|
else:
|
|
remainingCols += 1
|
|
newColWidth = None
|
|
newColWidths[i] = newColWidth
|
|
|
|
# Distribute remaining space
|
|
minCellWidth = totalWidth * 0.01
|
|
if remainingCols > 0:
|
|
for i, colWidth in enumerate(newColWidths):
|
|
if colWidth is None:
|
|
newColWidths[i] = max(
|
|
minCellWidth, remainingWidth / remainingCols
|
|
) # - 0.1
|
|
|
|
# Bigger than totalWidth? Lets reduce the fix entries propotionally
|
|
|
|
if sum(newColWidths) > totalWidth:
|
|
quotient = totalWidth / sum(newColWidths)
|
|
for i in range(len(newColWidths)):
|
|
newColWidths[i] = newColWidths[i] * quotient
|
|
|
|
# To avoid rounding errors adjust one col with the difference
|
|
diff = sum(newColWidths) - totalWidth
|
|
if diff > 0:
|
|
newColWidths[0] -= diff
|
|
|
|
return Table.wrap(self, availWidth, availHeight)
|
|
|
|
|
|
class PmlPageCount(IndexingFlowable):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.second_round = False
|
|
|
|
def isSatisfied(self):
|
|
s = self.second_round
|
|
self.second_round = True
|
|
return s
|
|
|
|
def drawOn(self, canvas, x, y, _sW=0):
|
|
pass
|
|
|
|
|
|
class PmlTableOfContents(TableOfContents):
|
|
def wrap(self, availWidth, availHeight):
|
|
"""All table properties should be known by now."""
|
|
widths = (availWidth - self.rightColumnWidth, self.rightColumnWidth)
|
|
|
|
# makes an internal table which does all the work.
|
|
# we draw the LAST RUN's entries! If there are
|
|
# none, we make some dummy data to keep the table
|
|
# from complaining
|
|
if len(self._lastEntries) == 0:
|
|
_tempEntries = [(0, "Placeholder for table of contents", 0)]
|
|
else:
|
|
_tempEntries = self._lastEntries
|
|
|
|
lastMargin = 0
|
|
tableData = []
|
|
tableStyle = [
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
|
("TOPPADDING", (0, 0), (-1, -1), 0),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
|
]
|
|
for i, entry in enumerate(_tempEntries):
|
|
level, text, pageNum = entry[:3]
|
|
leftColStyle = self.levelStyles[level]
|
|
if i: # Not for first element
|
|
tableStyle.append(
|
|
(
|
|
"TOPPADDING",
|
|
(0, i),
|
|
(-1, i),
|
|
max(lastMargin, leftColStyle.spaceBefore),
|
|
)
|
|
)
|
|
# print leftColStyle.leftIndent
|
|
lastMargin = leftColStyle.spaceAfter
|
|
# right col style is right aligned
|
|
rightColStyle = ParagraphStyle(
|
|
name="leftColLevel%d" % level,
|
|
parent=leftColStyle,
|
|
leftIndent=0,
|
|
alignment=TA_RIGHT,
|
|
)
|
|
leftPara = Paragraph(text, leftColStyle)
|
|
rightPara = Paragraph(str(pageNum), rightColStyle)
|
|
tableData.append([leftPara, rightPara])
|
|
|
|
self._table = Table(tableData, colWidths=widths, style=TableStyle(tableStyle))
|
|
|
|
self.width, self.height = self._table.wrapOn(self.canv, availWidth, availHeight)
|
|
return self.width, self.height
|
|
|
|
|
|
class PmlRightPageBreak(CondPageBreak):
|
|
def __init__(self) -> None:
|
|
pass
|
|
|
|
def wrap(self, availWidth, availHeight):
|
|
if not self.canv.getPageNumber() % 2:
|
|
self.width = availWidth
|
|
self.height = availHeight
|
|
return availWidth, availHeight
|
|
self.width = self.height = 0
|
|
return 0, 0
|
|
|
|
|
|
class PmlLeftPageBreak(CondPageBreak):
|
|
def __init__(self) -> None:
|
|
pass
|
|
|
|
def wrap(self, availWidth, availHeight):
|
|
if self.canv.getPageNumber() % 2:
|
|
self.width = availWidth
|
|
self.height = availHeight
|
|
return availWidth, availHeight
|
|
self.width = self.height = 0
|
|
return 0, 0
|
|
|
|
|
|
# --- Pdf Form
|
|
|
|
|
|
class PmlInput(Flowable):
|
|
def __init__(
|
|
self,
|
|
name,
|
|
input_type="text",
|
|
width=10,
|
|
height=10,
|
|
default="",
|
|
options=None,
|
|
multiline=0,
|
|
) -> None:
|
|
self.width = width
|
|
self.height = height
|
|
self.type = input_type
|
|
self.name = name
|
|
self.default = default
|
|
self.options = options if options is not None else []
|
|
self.multiline = multiline
|
|
|
|
def wrap(self, *args):
|
|
return self.width, self.height
|
|
|
|
def draw(self):
|
|
c = self.canv
|
|
|
|
c.saveState()
|
|
c.setFont("Helvetica", 10)
|
|
if self.type == "text":
|
|
pdfform.textFieldRelative(
|
|
c, self.name, 0, 0, self.width, self.height, multiline=self.multiline
|
|
)
|
|
c.rect(0, 0, self.width, self.height)
|
|
elif self.type == "radio":
|
|
c.rect(0, 0, self.width, self.height)
|
|
elif self.type == "checkbox":
|
|
if self.default:
|
|
pdfform.buttonFieldRelative(c, self.name, "Yes", 0, 0)
|
|
else:
|
|
pdfform.buttonFieldRelative(c, self.name, "Off", 0, 0)
|
|
c.rect(0, 0, self.width, self.height)
|
|
elif self.type == "select":
|
|
pdfform.selectFieldRelative(
|
|
c, self.name, self.default, self.options, 0, 0, self.width, self.height
|
|
)
|
|
c.rect(0, 0, self.width, self.height)
|
|
|
|
c.restoreState()
|