605 lines
17 KiB
Python
605 lines
17 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.
|
|
|
|
"""
|
|
A paragraph class to be used with ReportLab Platypus.
|
|
|
|
Todo:
|
|
----
|
|
- Bullets
|
|
- Weblinks and internal links
|
|
- Borders and margins (Box)
|
|
- Underline, Background, Strike
|
|
- Images
|
|
- Hyphenation
|
|
+ Alignment
|
|
+ Breakline, empty lines
|
|
+ TextIndent
|
|
- Sub and super
|
|
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
import re
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
from reportlab.lib.colors import Color
|
|
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
|
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
|
from reportlab.platypus.flowables import Flowable
|
|
|
|
if TYPE_CHECKING:
|
|
from reportlab.pdfgen.canvas import Canvas
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Style(dict):
|
|
"""
|
|
Style.
|
|
|
|
Single place for style definitions: Paragraphs and Fragments. The
|
|
naming follows the convention of CSS written in camelCase letters.
|
|
"""
|
|
|
|
DEFAULT: ClassVar[dict[str, Any]] = {
|
|
"color": Color(0, 0, 0),
|
|
"fontName": "Times-Roman",
|
|
"fontSize": 10.0,
|
|
"height": None,
|
|
"lineHeight": 1.5,
|
|
"lineHeightAbsolute": None,
|
|
"link": None,
|
|
"pdfLineSpacing": 0,
|
|
"textAlign": TA_LEFT,
|
|
"textIndent": 0.0,
|
|
"width": None,
|
|
}
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
self.update(self.DEFAULT)
|
|
self.update(kwargs)
|
|
self.spaceBefore: int = 0
|
|
self.spaceAfter: int = 0
|
|
self.leftIndent: int = 0
|
|
self.keepWithNext: bool = False
|
|
|
|
|
|
class Box(dict):
|
|
"""
|
|
Box.
|
|
|
|
Handles the following styles:
|
|
|
|
backgroundColor, backgroundImage
|
|
paddingLeft, paddingRight, paddingTop, paddingBottom
|
|
marginLeft, marginRight, marginTop, marginBottom
|
|
borderLeftColor, borderLeftWidth, borderLeftStyle
|
|
borderRightColor, borderRightWidth, borderRightStyle
|
|
borderTopColor, borderTopWidth, borderTopStyle
|
|
borderBottomColor, borderBottomWidth, borderBottomStyle
|
|
|
|
Not used in inline Elements:
|
|
|
|
paddingTop, paddingBottom
|
|
marginTop, marginBottom
|
|
|
|
"""
|
|
|
|
name: str = "box"
|
|
|
|
def drawBox(self, canvas: Canvas, x: int, y: int, w: int, h: int):
|
|
canvas.saveState()
|
|
|
|
# Background
|
|
bg = self.get("backgroundColor", None)
|
|
if bg is not None:
|
|
# draw a filled rectangle (with no stroke) using bg color
|
|
canvas.setFillColor(bg)
|
|
canvas.rect(x, y, w, h, fill=1, stroke=0)
|
|
|
|
# Borders
|
|
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 bstyle:
|
|
# If no color for border is given, the text color is used (like defined by W3C)
|
|
if color is None:
|
|
color = self.get("textColor", Color(0, 0, 0))
|
|
if color is not None:
|
|
canvas.setStrokeColor(color)
|
|
canvas.setLineWidth(width)
|
|
canvas.line(x1, y1, x2, y2)
|
|
|
|
_drawBorderLine(
|
|
self.get("borderLeftStyle", None),
|
|
self.get("borderLeftWidth", None),
|
|
self.get("borderLeftColor", None),
|
|
x,
|
|
y,
|
|
x,
|
|
y + h,
|
|
)
|
|
_drawBorderLine(
|
|
self.get("borderRightStyle", None),
|
|
self.get("borderRightWidth", None),
|
|
self.get("borderRightColor", None),
|
|
x + w,
|
|
y,
|
|
x + w,
|
|
y + h,
|
|
)
|
|
_drawBorderLine(
|
|
self.get("borderTopStyle", None),
|
|
self.get("borderTopWidth", None),
|
|
self.get("borderTopColor", None),
|
|
x,
|
|
y + h,
|
|
x + w,
|
|
y + h,
|
|
)
|
|
_drawBorderLine(
|
|
self.get("borderBottomStyle", None),
|
|
self.get("borderBottomWidth", None),
|
|
self.get("borderBottomColor", None),
|
|
x,
|
|
y,
|
|
x + w,
|
|
y,
|
|
)
|
|
|
|
canvas.restoreState()
|
|
|
|
|
|
class Fragment(Box):
|
|
"""
|
|
Fragment.
|
|
|
|
text: String containing text
|
|
fontName:
|
|
fontSize:
|
|
width: Width of string
|
|
height: Height of string
|
|
"""
|
|
|
|
name: str = "fragment"
|
|
isSoft: bool = False
|
|
isText: bool = False
|
|
isLF: bool = False
|
|
|
|
def calc(self) -> None:
|
|
self["width"] = 0
|
|
|
|
|
|
class Word(Fragment):
|
|
"""A single word."""
|
|
|
|
name: str = "word"
|
|
isText: bool = True
|
|
|
|
def calc(self) -> None:
|
|
"""XXX Cache stringWith if not accelerated?!."""
|
|
self["width"] = stringWidth(self["text"], self["fontName"], self["fontSize"])
|
|
|
|
|
|
class Space(Fragment):
|
|
"""A space between fragments that is the usual place for line breaking."""
|
|
|
|
name: str = "space"
|
|
isSoft: bool = True
|
|
|
|
def calc(self) -> None:
|
|
self["width"] = stringWidth(" ", self["fontName"], self["fontSize"])
|
|
|
|
|
|
class LineBreak(Fragment):
|
|
"""Line break."""
|
|
|
|
name: str = "br"
|
|
isSoft: bool = True
|
|
isLF: bool = True
|
|
|
|
|
|
class BoxBegin(Fragment):
|
|
name: str = "begin"
|
|
|
|
def calc(self) -> None:
|
|
self["width"] = self.get("marginLeft", 0) + self.get(
|
|
"paddingLeft", 0
|
|
) # + border if border
|
|
|
|
def draw(self, canvas, y):
|
|
# if not self["length"]:
|
|
x = self.get("marginLeft", 0) + self["x"]
|
|
w = self["length"] + self.get("paddingRight", 0)
|
|
h = self["fontSize"]
|
|
self.drawBox(canvas, x, y, w, h)
|
|
|
|
|
|
class BoxEnd(Fragment):
|
|
name: str = "end"
|
|
|
|
def calc(self) -> None:
|
|
self["width"] = self.get("marginRight", 0) + self.get(
|
|
"paddingRight", 0
|
|
) # + border
|
|
|
|
|
|
class Image(Fragment):
|
|
name: str = "image"
|
|
|
|
|
|
class Line(list):
|
|
"""Container for line fragments."""
|
|
|
|
LINEHEIGHT: float = 1.0
|
|
|
|
def __init__(self, style) -> None:
|
|
self.width: int = 0
|
|
self.height: int = 0
|
|
self.isLast: bool = False
|
|
self.style = style
|
|
self.boxStack: list = []
|
|
super().__init__()
|
|
|
|
def doAlignment(self, width, alignment):
|
|
# Apply alignment
|
|
if alignment != TA_LEFT:
|
|
lineWidth = self[-1]["x"] + self[-1]["width"]
|
|
emptySpace = width - lineWidth
|
|
if alignment == TA_RIGHT:
|
|
for frag in self:
|
|
frag["x"] += emptySpace
|
|
elif alignment == TA_CENTER:
|
|
for frag in self:
|
|
frag["x"] += emptySpace / 2.0
|
|
elif (
|
|
alignment == TA_JUSTIFY and not self.isLast
|
|
): # XXX last line before split
|
|
delta = emptySpace / (len(self) - 1)
|
|
for i, frag in enumerate(self):
|
|
frag["x"] += i * delta
|
|
|
|
# Boxes
|
|
for frag in self:
|
|
x = frag["x"] + frag["width"]
|
|
if isinstance(frag, BoxBegin):
|
|
self.boxStack.append(frag)
|
|
elif isinstance(frag, BoxEnd) and self.boxStack:
|
|
frag = self.boxStack.pop()
|
|
frag["length"] = x - frag["x"]
|
|
|
|
# Handle the rest
|
|
for frag in self.boxStack:
|
|
frag["length"] = x - frag["x"]
|
|
|
|
def doLayout(self, width):
|
|
"""Align words in previous line."""
|
|
# Calculate dimensions
|
|
self.width = width
|
|
|
|
font_sizes = [0] + [frag.get("fontSize", 0) for frag in self]
|
|
self.fontSize = max(font_sizes)
|
|
self.height = self.lineHeight = max(
|
|
frag * self.LINEHEIGHT for frag in font_sizes
|
|
)
|
|
|
|
# Apply line height
|
|
y = self.lineHeight - self.fontSize # / 2
|
|
for frag in self:
|
|
frag["y"] = y
|
|
|
|
return self.height
|
|
|
|
def dumpFragments(self):
|
|
logger.debug("Line")
|
|
logger.debug(40 * "-")
|
|
for frag in self:
|
|
logger.debug("%s", frag.get("text", frag.name.upper()))
|
|
|
|
|
|
class Text(list):
|
|
"""
|
|
Container for text fragments.
|
|
|
|
Helper functions for splitting text into lines and calculating sizes
|
|
and positions.
|
|
"""
|
|
|
|
def __init__(self, data: list | None = None, style: Style | None = None) -> None:
|
|
# Mutable arguments are a shit idea
|
|
if data is None:
|
|
data = []
|
|
|
|
self.lines: list = []
|
|
self.width: int = 0
|
|
self.height: int = 0
|
|
self.maxWidth: int = 0
|
|
self.maxHeight: int = 0
|
|
self.style: Style | None = style
|
|
super().__init__(data)
|
|
|
|
def calc(self) -> None:
|
|
"""Calculate sizes of fragments."""
|
|
for word in self:
|
|
word.calc()
|
|
|
|
def splitIntoLines(
|
|
self, maxWidth: int, maxHeight: int, *, splitted: bool = False
|
|
) -> int | None:
|
|
"""
|
|
Split text into lines and calculate X positions. If we need more
|
|
space in height than available we return the rest of the text.
|
|
"""
|
|
self.lines = []
|
|
self.height = 0
|
|
self.width = maxWidth
|
|
self.maxHeight = maxHeight
|
|
self.maxWidth = maxWidth
|
|
boxStack: list = []
|
|
|
|
style = self.style
|
|
x: int = 0
|
|
|
|
# Start with indent in first line of text
|
|
if not splitted and style:
|
|
x = style["textIndent"]
|
|
|
|
lenText: int = len(self)
|
|
pos: int = 0
|
|
while pos < lenText:
|
|
# Reset values for new line
|
|
posBegin = pos
|
|
line = Line(style)
|
|
|
|
# Update boxes for next line
|
|
for box in copy.copy(boxStack):
|
|
box["x"] = 0
|
|
line.append(BoxBegin(box))
|
|
|
|
while pos < lenText:
|
|
# Get fragment, its width and set X
|
|
frag = self[pos]
|
|
fragWidth = frag["width"]
|
|
frag["x"] = x
|
|
pos += 1
|
|
|
|
# Keep in mind boxes for next lines
|
|
if isinstance(frag, BoxBegin):
|
|
boxStack.append(frag)
|
|
elif isinstance(frag, BoxEnd):
|
|
boxStack.pop()
|
|
|
|
# If space or linebreak handle special way
|
|
if frag.isSoft:
|
|
if frag.isLF:
|
|
line.append(frag)
|
|
break
|
|
# First element of line should not be a space
|
|
if x == 0:
|
|
continue
|
|
# Keep in mind last possible line break
|
|
|
|
# The elements exceed the current line
|
|
elif fragWidth + x > maxWidth:
|
|
break
|
|
|
|
# Add fragment to line and update x
|
|
x += fragWidth
|
|
line.append(frag)
|
|
|
|
# Remove trailing white spaces
|
|
while line and line[-1].name in ("space", "br"):
|
|
line.pop()
|
|
|
|
# Add line to list
|
|
line.dumpFragments()
|
|
# if line:
|
|
self.height += line.doLayout(self.width)
|
|
self.lines.append(line)
|
|
|
|
# If not enough space for current line force to split
|
|
if self.height > maxHeight:
|
|
return posBegin
|
|
|
|
# Reset variables
|
|
x = 0
|
|
|
|
# Apply alignment
|
|
self.lines[-1].isLast = True
|
|
if style:
|
|
for line in self.lines:
|
|
line.doAlignment(maxWidth, style["textAlign"])
|
|
|
|
return None
|
|
|
|
def dumpLines(self):
|
|
"""For debugging dump all line and their content."""
|
|
for i, line in enumerate(self.lines):
|
|
logger.debug("Line %d:", i)
|
|
logger.debug(line.dumpFragments())
|
|
|
|
def __getitem__(self, key):
|
|
"""Make sure slices return also Text object and not lists"""
|
|
if isinstance(key, slice):
|
|
return type(self)(super().__getitem__(key))
|
|
return super().__getitem__(key)
|
|
|
|
|
|
class Paragraph(Flowable):
|
|
"""
|
|
A simple Paragraph class respecting alignment.
|
|
|
|
Does text without tags.
|
|
|
|
Respects only the following global style attributes:
|
|
fontName, fontSize, leading, firstLineIndent, leftIndent,
|
|
rightIndent, textColor, alignment.
|
|
(spaceBefore, spaceAfter are handled by the Platypus framework.)
|
|
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
text: Text,
|
|
style: Style,
|
|
*,
|
|
debug: bool = False,
|
|
splitted: bool = False,
|
|
**kwDict,
|
|
) -> None:
|
|
super().__init__()
|
|
|
|
self.text: Text = text
|
|
self.text.calc()
|
|
self.style: Style = style
|
|
self.text.style = style
|
|
|
|
self.debug: bool = debug
|
|
self.splitted: bool = splitted
|
|
|
|
# More attributes
|
|
for k, v in kwDict.items():
|
|
setattr(self, k, v)
|
|
|
|
# set later...
|
|
self.splitIndex: int | None = None
|
|
|
|
# overwritten methods from Flowable class
|
|
def wrap(self, availWidth: int, availHeight: int) -> tuple[int, int]:
|
|
"""Determine the rectangle this paragraph really needs."""
|
|
# memorize available space
|
|
self.avWidth: int = availWidth
|
|
self.avHeight: int = availHeight
|
|
|
|
logger.debug("*** wrap (%f, %f)", availWidth, availHeight)
|
|
|
|
if not self.text:
|
|
logger.debug("*** wrap (%f, %f) needed", 0, 0)
|
|
return 0, 0
|
|
|
|
# Split lines
|
|
width: int = availWidth
|
|
self.splitIndex = self.text.splitIntoLines(width, availHeight)
|
|
|
|
self.width: int = availWidth
|
|
self.height: int = self.text.height
|
|
|
|
logger.debug(
|
|
"*** wrap (%f, %f) needed, splitIndex %r",
|
|
self.width,
|
|
self.height,
|
|
self.splitIndex,
|
|
)
|
|
|
|
return self.width, self.height
|
|
|
|
def split(self, availWidth: int, availHeight: int) -> list[Paragraph]:
|
|
"""Split ourselves in two paragraphs."""
|
|
logger.debug("*** split (%f, %f)", availWidth, availHeight)
|
|
|
|
splitted: list[Paragraph] = []
|
|
if self.splitIndex:
|
|
text1: Text = self.text[: self.splitIndex]
|
|
text2: Text = self.text[self.splitIndex :]
|
|
p1: Paragraph = Paragraph(Text(text1), self.style, debug=self.debug)
|
|
p2: Paragraph = Paragraph(
|
|
Text(text2), self.style, debug=self.debug, splitted=True
|
|
)
|
|
splitted = [p1, p2]
|
|
|
|
logger.debug("*** text1 %s / text %s", len(text1), len(text2))
|
|
|
|
logger.debug("*** return %s", self.splitted)
|
|
|
|
return splitted
|
|
|
|
def draw(self) -> None:
|
|
"""Render the content of the paragraph."""
|
|
logger.debug("*** draw")
|
|
|
|
if not self.text:
|
|
return
|
|
|
|
canvas: Canvas = self.canv
|
|
style: Style = self.style
|
|
|
|
canvas.saveState()
|
|
|
|
# Draw box arround paragraph for debugging
|
|
if self.debug:
|
|
bw: float = 0.5
|
|
bc: Color = Color(1, 1, 0)
|
|
bg: Color = Color(0.9, 0.9, 0.9)
|
|
canvas.setStrokeColor(bc)
|
|
canvas.setLineWidth(bw)
|
|
canvas.setFillColor(bg)
|
|
canvas.rect(style.leftIndent, 0, self.width, self.height, fill=1, stroke=1)
|
|
|
|
y: int = 0
|
|
dy: int = self.height
|
|
for line in self.text.lines:
|
|
y += line.height
|
|
for frag in line:
|
|
# Box
|
|
if hasattr(frag, "draw"):
|
|
frag.draw(canvas, dy - y)
|
|
|
|
# Text
|
|
if frag.get("text", ""):
|
|
canvas.setFont(frag["fontName"], frag["fontSize"])
|
|
canvas.setFillColor(frag.get("color", style["color"]))
|
|
canvas.drawString(frag["x"], dy - y + frag["y"], frag["text"])
|
|
|
|
# XXX LINK
|
|
link: bytes | str = frag.get("link", None)
|
|
if link:
|
|
_scheme_re = re.compile("^[a-zA-Z][-+a-zA-Z0-9]+$")
|
|
x, y, w, h = frag["x"], dy - y, frag["width"], frag["fontSize"]
|
|
rect = (x, y, w, h)
|
|
if isinstance(link, bytes):
|
|
link = link.decode("utf8")
|
|
parts = link.split(":", maxsplit=1)
|
|
scheme = len(parts) == 2 and parts[0].lower() or ""
|
|
if _scheme_re.match(scheme) and scheme != "document":
|
|
kind = scheme.lower() == "pdf" and "GoToR" or "URI"
|
|
if kind == "GoToR":
|
|
link = parts[1]
|
|
|
|
canvas.linkURL(link, rect, relative=1, kind=kind)
|
|
else:
|
|
if link[0] == "#":
|
|
link = link[1:]
|
|
scheme = ""
|
|
canvas.linkRect(
|
|
"",
|
|
scheme != "document" and link or parts[1],
|
|
rect,
|
|
relative=1,
|
|
)
|
|
|
|
canvas.restoreState()
|
|
|
|
|
|
class PageNumberFlowable(Flowable):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.page: str | None = None
|
|
self.pagecount: str | None = None
|
|
|
|
def draw(self) -> None:
|
|
self.page = str(self.canv._doctemplate.page)
|
|
self.pagecount = str(self.canv._doctemplate._page_count)
|