1158 lines
38 KiB
Python
1158 lines
38 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.
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
import re
|
|
import urllib.parse as urlparse
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Callable
|
|
|
|
from reportlab import rl_settings
|
|
from reportlab.lib.enums import TA_LEFT
|
|
from reportlab.lib.fonts import addMapping
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.pdfbase import pdfmetrics
|
|
from reportlab.pdfbase.ttfonts import TTFont
|
|
from reportlab.platypus.frames import Frame, ShowBoundaryValue
|
|
from reportlab.platypus.paraparser import ParaFrag, ps2tt, tt2ps
|
|
|
|
from xhtml2pdf import default, parser
|
|
from xhtml2pdf.files import getFile, pisaFileObject
|
|
from xhtml2pdf.tables import TableData
|
|
from xhtml2pdf.util import (
|
|
arabic_format,
|
|
copy_attrs,
|
|
frag_text_language_check,
|
|
get_default_asian_font,
|
|
getColor,
|
|
getCoords,
|
|
getFloat,
|
|
getFrameDimensions,
|
|
getSize,
|
|
set_asian_fonts,
|
|
set_value,
|
|
)
|
|
from xhtml2pdf.w3c import css
|
|
from xhtml2pdf.xhtml2pdf_reportlab import (
|
|
PmlPageCount,
|
|
PmlPageTemplate,
|
|
PmlParagraph,
|
|
PmlParagraphAndImage,
|
|
PmlTableOfContents,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from reportlab.platypus.flowables import Flowable
|
|
|
|
from xhtml2pdf.xhtml2pdf_reportlab import PmlImage
|
|
|
|
|
|
rl_settings.warnOnMissingFontGlyphs = 0
|
|
log = logging.getLogger(__name__)
|
|
|
|
sizeDelta = 2 # amount to reduce font size by for super and sub script
|
|
subFraction = 0.4 # fraction of font size that a sub script should be lowered
|
|
superFraction = 0.4
|
|
|
|
NBSP = "\u00a0"
|
|
|
|
|
|
def clone(self, **kwargs) -> ParaFrag:
|
|
n = ParaFrag(**self.__dict__)
|
|
if kwargs:
|
|
d = n.__dict__
|
|
d.update(kwargs)
|
|
# This else could cause trouble in Paragraphs with images etc.
|
|
if "cbDefn" in d:
|
|
del d["cbDefn"]
|
|
n.bulletText = None
|
|
return n
|
|
|
|
|
|
ParaFrag.clone = clone
|
|
|
|
|
|
def getParaFrag(style) -> ParaFrag:
|
|
frag: ParaFrag = ParaFrag()
|
|
|
|
set_value(
|
|
frag,
|
|
(
|
|
"sub",
|
|
"super",
|
|
"rise",
|
|
"underline",
|
|
"strike",
|
|
"greek",
|
|
"leading",
|
|
"leadingSpace",
|
|
"spaceBefore",
|
|
"spaceAfter",
|
|
"leftIndent",
|
|
"rightIndent",
|
|
"firstLineIndent",
|
|
"borderPadding",
|
|
"paddingLeft",
|
|
"paddingRight",
|
|
"paddingTop",
|
|
"paddingBottom",
|
|
"bulletIndent",
|
|
"insideStaticFrame",
|
|
"outlineLevel",
|
|
),
|
|
0,
|
|
)
|
|
|
|
set_value(
|
|
frag,
|
|
(
|
|
"backColor",
|
|
"vAlign",
|
|
"link",
|
|
"borderStyle",
|
|
"borderColor",
|
|
"listStyleType",
|
|
"listStyleImage",
|
|
"wordWrap",
|
|
"height",
|
|
"width",
|
|
"bulletText",
|
|
),
|
|
None,
|
|
)
|
|
set_value(
|
|
frag,
|
|
("pageNumber", "pageCount", "outline", "outlineOpen", "keepWithNext", "rtl"),
|
|
False, # noqa: FBT003
|
|
)
|
|
|
|
frag.text = ""
|
|
frag.fontName = "Times-Roman"
|
|
frag.fontName, frag.bold, frag.italic = ps2tt(style.fontName)
|
|
frag.fontSize = style.fontSize
|
|
frag.textColor = style.textColor
|
|
|
|
# Extras
|
|
frag.letterSpacing = "normal"
|
|
frag.leadingSource = "150%"
|
|
frag.alignment = TA_LEFT
|
|
frag.borderWidth = 1
|
|
|
|
frag.borderLeftWidth = frag.borderWidth
|
|
frag.borderLeftColor = frag.borderColor
|
|
frag.borderLeftStyle = frag.borderStyle
|
|
frag.borderRightWidth = frag.borderWidth
|
|
frag.borderRightColor = frag.borderColor
|
|
frag.borderRightStyle = frag.borderStyle
|
|
frag.borderTopWidth = frag.borderWidth
|
|
frag.borderTopColor = frag.borderColor
|
|
frag.borderTopStyle = frag.borderStyle
|
|
frag.borderBottomWidth = frag.borderWidth
|
|
frag.borderBottomColor = frag.borderColor
|
|
frag.borderBottomStyle = frag.borderStyle
|
|
|
|
frag.whiteSpace = "normal"
|
|
frag.bulletFontName = "Helvetica"
|
|
frag.zoom = 1.0
|
|
|
|
return frag
|
|
|
|
|
|
def getDirName(path) -> str:
|
|
parts = urlparse.urlparse(path)
|
|
if parts.scheme:
|
|
return path
|
|
return str(Path(path).parent.resolve())
|
|
|
|
|
|
class pisaCSSBuilder(css.CSSBuilder):
|
|
c: pisaContext
|
|
|
|
def atFontFace(self, declarations) -> tuple[dict, dict]:
|
|
"""Embed fonts."""
|
|
result = self.ruleset([self.selector("*")], declarations)
|
|
data = next(iter(result[0].values()))
|
|
if "src" not in data:
|
|
# invalid - source is required, ignore this specification
|
|
return {}, {}
|
|
names = data["font-family"]
|
|
|
|
# Font weight
|
|
fweight = str(data.get("font-weight", "normal")).lower()
|
|
bold = fweight in ("bold", "bolder", "500", "600", "700", "800", "900")
|
|
if not bold and fweight != "normal":
|
|
log.warning(
|
|
self.c.warning("@fontface, unknown value font-weight '%s'", fweight)
|
|
)
|
|
|
|
# Font style
|
|
italic = str(data.get("font-style", "")).lower() in ("italic", "oblique")
|
|
|
|
# The "src" attribute can be a CSS group but in that case
|
|
# ignore everything except the font URI
|
|
if isinstance(data["src"], list):
|
|
fonts = [part for part in data["src"] if isinstance(part, str)]
|
|
else:
|
|
fonts = [data["src"]]
|
|
|
|
for font in fonts:
|
|
src = self.c.getFile(font, relative=self.c.cssParser.rootPath)
|
|
if src and not src.notFound():
|
|
self.c.loadFont(names, src, bold=bold, italic=italic)
|
|
return {}, {}
|
|
|
|
def _pisaAddFrame(
|
|
self,
|
|
name: str,
|
|
data: dict,
|
|
*,
|
|
first: bool = False,
|
|
border=None,
|
|
size: tuple[float, float] = (0, 0),
|
|
) -> tuple[str, str | None, str | None, float, float, float, float, dict]:
|
|
c = self.c
|
|
if not name:
|
|
name = "-pdf-frame-%d" % c.UID()
|
|
if data.get("is_landscape", False):
|
|
size = (size[1], size[0])
|
|
x, y, w, h = getFrameDimensions(data, size[0], size[1])
|
|
# print name, x, y, w, h
|
|
# if not (w and h):
|
|
# return None
|
|
if first:
|
|
return name, None, data.get("-pdf-frame-border", border), x, y, w, h, data
|
|
|
|
return (
|
|
name,
|
|
data.get("-pdf-frame-content", None),
|
|
data.get("-pdf-frame-border", border),
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
data,
|
|
)
|
|
|
|
@staticmethod
|
|
def _getFromData(data, attr, default=None, func: Callable | None = None):
|
|
if not func:
|
|
|
|
def func(x):
|
|
return x
|
|
|
|
if isinstance(attr, (list, tuple)):
|
|
for a in attr:
|
|
return func(data[a]) if a in data else default
|
|
return None
|
|
return func(data[attr]) if attr in data else default
|
|
|
|
@staticmethod
|
|
def get_background_context(data: dict) -> dict:
|
|
object_position = data.get("background-object-position")
|
|
height = data.get("background-height")
|
|
width = data.get("background-width")
|
|
opacity = data.get("background-opacity")
|
|
dev: dict = {"step": getFloat(data.get("background-page-step", 1))}
|
|
if object_position:
|
|
dev["object_position"] = [
|
|
getSize(object_position[0]),
|
|
getSize(object_position[1]),
|
|
]
|
|
if height:
|
|
dev["height"] = getSize(height)
|
|
if width:
|
|
dev["width"] = getSize(width)
|
|
if opacity:
|
|
dev["opacity"] = getFloat(opacity)
|
|
return dev
|
|
|
|
def atPage(
|
|
self,
|
|
name: str,
|
|
pseudopage: str | None,
|
|
data: dict,
|
|
*,
|
|
isLandscape: bool,
|
|
pageBorder,
|
|
) -> tuple[dict, dict]:
|
|
c = self.c
|
|
name = name or "body"
|
|
|
|
if name in c.templateList:
|
|
log.warning(self.c.warning("template '%s' has already been defined", name))
|
|
|
|
padding_top = self._getFromData(data, "padding-top", 0, getSize)
|
|
padding_left = self._getFromData(data, "padding-left", 0, getSize)
|
|
padding_right = self._getFromData(data, "padding-right", 0, getSize)
|
|
padding_bottom = self._getFromData(data, "padding-bottom", 0, getSize)
|
|
border_color = self._getFromData(
|
|
data,
|
|
(
|
|
"border-top-color",
|
|
"border-bottom-color",
|
|
"border-left-color",
|
|
"border-right-color",
|
|
),
|
|
None,
|
|
getColor,
|
|
)
|
|
border_width = self._getFromData(
|
|
data,
|
|
(
|
|
"border-top-width",
|
|
"border-bottom-width",
|
|
"border-left-width",
|
|
"border-right-width",
|
|
),
|
|
0,
|
|
getSize,
|
|
)
|
|
|
|
for prop in (
|
|
"margin-top",
|
|
"margin-left",
|
|
"margin-right",
|
|
"margin-bottom",
|
|
"top",
|
|
"left",
|
|
"right",
|
|
"bottom",
|
|
"width",
|
|
"height",
|
|
):
|
|
if prop in data:
|
|
c.frameList.append(
|
|
self._pisaAddFrame(
|
|
name, data, first=True, border=pageBorder, size=c.pageSize
|
|
)
|
|
)
|
|
break
|
|
|
|
# Frames have to be calculated after we know the pagesize
|
|
frameList = []
|
|
staticList = []
|
|
for fname, static, border, x, y, w, h, fdata in c.frameList:
|
|
fpadding_top = self._getFromData(fdata, "padding-top", padding_top, getSize)
|
|
fpadding_left = self._getFromData(
|
|
fdata, "padding-left", padding_left, getSize
|
|
)
|
|
fpadding_right = self._getFromData(
|
|
fdata, "padding-right", padding_right, getSize
|
|
)
|
|
fpadding_bottom = self._getFromData(
|
|
fdata, "padding-bottom", padding_bottom, getSize
|
|
)
|
|
fborder_color = self._getFromData(
|
|
fdata,
|
|
(
|
|
"border-top-color",
|
|
"border-bottom-color",
|
|
"border-left-color",
|
|
"border-right-color",
|
|
),
|
|
border_color,
|
|
getColor,
|
|
)
|
|
fborder_width = self._getFromData(
|
|
fdata,
|
|
(
|
|
"border-top-width",
|
|
"border-bottom-width",
|
|
"border-left-width",
|
|
"border-right-width",
|
|
),
|
|
border_width,
|
|
getSize,
|
|
)
|
|
|
|
if border or pageBorder:
|
|
frame_border = ShowBoundaryValue(
|
|
width=int(border)
|
|
) # frame_border = ShowBoundaryValue() to
|
|
# frame_border = ShowBoundaryValue(width=int(border))
|
|
else:
|
|
frame_border = ShowBoundaryValue(
|
|
color=fborder_color, width=fborder_width
|
|
)
|
|
|
|
# fix frame sizing problem.
|
|
if static:
|
|
x, y, w, h = getFrameDimensions(fdata, c.pageSize[0], c.pageSize[1])
|
|
x, y, w, h = getCoords(x, y, w, h, c.pageSize)
|
|
if w <= 0 or h <= 0:
|
|
log.warning(
|
|
self.c.warning(
|
|
"Negative width or height of frame. Check @frame definitions."
|
|
)
|
|
)
|
|
|
|
frame = Frame(
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
id=fname,
|
|
leftPadding=fpadding_left,
|
|
rightPadding=fpadding_right,
|
|
bottomPadding=fpadding_bottom,
|
|
topPadding=fpadding_top,
|
|
showBoundary=frame_border,
|
|
)
|
|
|
|
if static:
|
|
frame.pisaStaticStory = []
|
|
c.frameStatic[static] = [frame, *c.frameStatic.get(static, [])]
|
|
staticList.append(frame)
|
|
else:
|
|
frameList.append(frame)
|
|
|
|
background = data.get("background-image", None)
|
|
background_context = self.get_background_context(data)
|
|
if background:
|
|
# should be relative to the css file
|
|
background = self.c.getFile(background, relative=self.c.cssParser.rootPath)
|
|
|
|
if not frameList:
|
|
log.warning(
|
|
c.warning(
|
|
"missing explicit frame definition for content or just static"
|
|
" frames"
|
|
)
|
|
)
|
|
fname, static, border, x, y, w, h, data = self._pisaAddFrame(
|
|
name, data, first=True, border=pageBorder, size=c.pageSize
|
|
)
|
|
x, y, w, h = getCoords(x, y, w, h, c.pageSize)
|
|
if w <= 0 or h <= 0:
|
|
log.warning(
|
|
c.warning(
|
|
"Negative width or height of frame. Check @page definitions."
|
|
)
|
|
)
|
|
|
|
if border or pageBorder:
|
|
frame_border = ShowBoundaryValue()
|
|
else:
|
|
frame_border = ShowBoundaryValue(color=border_color, width=border_width)
|
|
|
|
frameList.append(
|
|
Frame(
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
id=fname,
|
|
leftPadding=padding_left,
|
|
rightPadding=padding_right,
|
|
bottomPadding=padding_bottom,
|
|
topPadding=padding_top,
|
|
showBoundary=frame_border,
|
|
)
|
|
)
|
|
|
|
pt = PmlPageTemplate(id=name, frames=frameList, pagesize=c.pageSize)
|
|
pt.pisaStaticList = staticList
|
|
pt.pisaBackground = background
|
|
pt.pisaBackgroundList = c.pisaBackgroundList
|
|
pt.backgroundContext = background_context
|
|
|
|
if isLandscape:
|
|
pt.pageorientation = pt.LANDSCAPE
|
|
|
|
c.templateList[name] = pt
|
|
c.template = None
|
|
c.frameList = []
|
|
c.frameStaticList = []
|
|
|
|
return {}, {}
|
|
|
|
def atFrame(self, name: str, declarations) -> tuple[dict, dict]:
|
|
if declarations:
|
|
result = self.ruleset([self.selector("*")], declarations)
|
|
# print "@BOX", name, declarations, result
|
|
|
|
data = result[0]
|
|
if data:
|
|
try:
|
|
data = data.values()[0]
|
|
except Exception:
|
|
data = data.popitem()[1]
|
|
self.c.frameList.append(
|
|
self._pisaAddFrame(name, data, size=self.c.pageSize)
|
|
)
|
|
|
|
return {}, {} # TODO: It always returns empty dicts?
|
|
|
|
|
|
class pisaCSSParser(css.CSSParser):
|
|
def parseExternal(self, cssResourceName):
|
|
result = None
|
|
oldRootPath = self.rootPath
|
|
cssFile = self.c.getFile(cssResourceName, relative=self.rootPath)
|
|
if not cssFile:
|
|
return None
|
|
if self.rootPath and urlparse.urlparse(self.rootPath).scheme:
|
|
self.rootPath = urlparse.urljoin(self.rootPath, cssResourceName)
|
|
else:
|
|
self.rootPath = getDirName(cssFile.uri)
|
|
try:
|
|
result = self.parse(cssFile.getData())
|
|
self.rootPath = oldRootPath
|
|
except Exception:
|
|
log.exception("Error while parsing CSS file")
|
|
return result
|
|
|
|
|
|
class PageNumberText:
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
self.data: str = ""
|
|
|
|
def __contains__(self, key) -> bool:
|
|
if self.flowable.page is not None:
|
|
self.data = str(self.flowable.page)
|
|
return False
|
|
|
|
def split(self, text: str) -> list[str]:
|
|
return [self.data]
|
|
|
|
def __getitem__(self, index: int) -> str:
|
|
return self.data[index] if self.data else self.data
|
|
|
|
def setFlowable(self, flowable: Flowable) -> None:
|
|
self.flowable = flowable
|
|
|
|
def __str__(self) -> str:
|
|
return self.data
|
|
|
|
|
|
class PageCountText:
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
self.data: str = ""
|
|
|
|
def __str__(self) -> str:
|
|
return self.data
|
|
|
|
def __contains__(self, key) -> bool:
|
|
if self.flowable.pagecount is not None:
|
|
self.data = str(self.flowable.pagecount)
|
|
return False
|
|
|
|
def split(self, text: str) -> list[str]:
|
|
return [self.data]
|
|
|
|
def __getitem__(self, index: int) -> str:
|
|
return self.data if not self.data else self.data[index]
|
|
|
|
def setFlowable(self, flowable: Flowable) -> None:
|
|
self.flowable = flowable
|
|
|
|
|
|
def reverse_sentence(sentence: str) -> str:
|
|
words = sentence.split(" ")
|
|
reverse_sentence = " ".join(reversed(words))
|
|
return reverse_sentence[::-1]
|
|
|
|
|
|
class pisaContext:
|
|
"""
|
|
Helper class for creation of reportlab story and container for
|
|
various data.
|
|
"""
|
|
|
|
def __init__(self, path: str = "", debug: int = 0, capacity: int = -1) -> None:
|
|
self.fontList: dict[str, str] = copy.copy(default.DEFAULT_FONT)
|
|
self.asianFontList: dict[str, str] = copy.copy(get_default_asian_font())
|
|
self.anchorFrag: list = []
|
|
self.anchorName: list = []
|
|
self.fragAnchor: list = []
|
|
self.fragList: list = []
|
|
self.fragStack: list = []
|
|
self.frameList: list = []
|
|
self.frameStaticList: list = []
|
|
self.frameStatioundList: list = []
|
|
self.log: list = []
|
|
self.path: list = []
|
|
self.pisaBackgroundList: list = []
|
|
self.select_options: list[str] = []
|
|
self.story: list = []
|
|
self.image: PmlImage | None = None
|
|
self.indexing_story: PmlPageCount | None = None
|
|
self.keepInFrameIndex = None
|
|
self.node = None
|
|
self.template = None
|
|
self.tableData: TableData = TableData()
|
|
self.err: int = 0
|
|
self.fontSize: float = 0.0
|
|
self.listCounter: int = 0
|
|
self.uidctr: int = 0
|
|
self.warn: int = 0
|
|
self.cssDefaultText: str = ""
|
|
self.cssText: str = ""
|
|
self.language: str = ""
|
|
self.text: str = ""
|
|
self.frameStatic: dict = {}
|
|
self.imageData: dict = {}
|
|
self.templateList: dict = {}
|
|
self.capacity: int = capacity
|
|
self.toc: PmlTableOfContents = PmlTableOfContents()
|
|
self.multiBuild: bool = False
|
|
self.pageSize: tuple[float, float] = A4
|
|
self.baseFontSize: float = getSize("12pt")
|
|
self.frag: ParaFrag = getParaFrag(ParagraphStyle(f"default{self.UID()}"))
|
|
self.fragBlock: ParaFrag = self.frag
|
|
self.fragStrip: bool = True
|
|
self.force: bool = False
|
|
self.dir: str = "ltr"
|
|
|
|
#: External callback function for path calculations
|
|
self.pathCallback = None
|
|
|
|
# Store path to document
|
|
self.pathDocument: str = path or "__dummy__"
|
|
parts = urlparse.urlparse(self.pathDocument)
|
|
if not parts.scheme:
|
|
self.pathDocument = str(Path(self.pathDocument).absolute().resolve())
|
|
self.pathDirectory: str = getDirName(self.pathDocument)
|
|
|
|
self.meta: dict[str, str | tuple[float, float]] = {
|
|
"author": "",
|
|
"title": "",
|
|
"subject": "",
|
|
"keywords": "",
|
|
"pagesize": A4,
|
|
}
|
|
|
|
def setDir(self, direction):
|
|
if direction == "rtl":
|
|
self.frag.rtl = True
|
|
self.dir = direction
|
|
|
|
def UID(self):
|
|
self.uidctr += 1
|
|
return self.uidctr
|
|
|
|
# METHODS FOR CSS
|
|
def addCSS(self, value):
|
|
value = value.strip()
|
|
if value.startswith("<![CDATA["):
|
|
value = value[9:-3]
|
|
if value.startswith("<!--"):
|
|
value = value[4:-3]
|
|
self.cssText += value.strip() + "\n"
|
|
|
|
# METHODS FOR CSS
|
|
def addDefaultCSS(self, value):
|
|
value = value.strip()
|
|
if value.startswith("<![CDATA["):
|
|
value = value[9:-3]
|
|
if value.startswith("<!--"):
|
|
value = value[4:-3]
|
|
self.cssDefaultText += value.strip() + "\n"
|
|
|
|
def parseCSS(self):
|
|
# This self-reference really should be refactored. But for now
|
|
# we'll settle for using weak references. This avoids memory
|
|
# leaks because the garbage collector (at least on cPython
|
|
# 2.7.3) isn't aggressive enough.
|
|
import weakref
|
|
|
|
self.cssBuilder = pisaCSSBuilder(mediumSet=["all", "print", "pdf"])
|
|
# self.cssBuilder.c = self
|
|
self.cssBuilder._c = weakref.ref(self)
|
|
pisaCSSBuilder.c = property(lambda self: self._c())
|
|
|
|
self.cssParser = pisaCSSParser(self.cssBuilder)
|
|
self.cssParser.rootPath = self.pathDirectory
|
|
# self.cssParser.c = self
|
|
self.cssParser._c = weakref.ref(self)
|
|
pisaCSSParser.c = property(lambda self: self._c())
|
|
|
|
self.css = self.cssParser.parse(self.cssText)
|
|
self.cssDefault = self.cssParser.parse(self.cssDefaultText)
|
|
self.cssCascade = css.CSSCascadeStrategy(
|
|
userAgent=self.cssDefault, user=self.css
|
|
)
|
|
self.cssCascade.parser = self.cssParser
|
|
|
|
# METHODS FOR STORY
|
|
def addStory(self, data):
|
|
self.story.append(data)
|
|
|
|
def swapStory(self, story=None):
|
|
story = story if story is not None else []
|
|
self.story, story = copy.copy(story), copy.copy(self.story)
|
|
return story
|
|
|
|
def toParagraphStyle(self, first) -> ParagraphStyle:
|
|
style = ParagraphStyle(
|
|
"default%d" % self.UID(), keepWithNext=first.keepWithNext
|
|
)
|
|
|
|
copy_attrs(
|
|
style,
|
|
first,
|
|
(
|
|
"fontName",
|
|
"fontSize",
|
|
"letterSpacing",
|
|
"backColor",
|
|
"spaceBefore",
|
|
"spaceAfter",
|
|
"leftIndent",
|
|
"rightIndent",
|
|
"firstLineIndent",
|
|
"textColor",
|
|
"alignment",
|
|
"bulletIndent",
|
|
"wordWrap",
|
|
"borderTopStyle",
|
|
"borderTopWidth",
|
|
"borderTopColor",
|
|
"borderBottomStyle",
|
|
"borderBottomWidth",
|
|
"borderBottomColor",
|
|
"borderLeftStyle",
|
|
"borderLeftWidth",
|
|
"borderLeftColor",
|
|
"borderRightStyle",
|
|
"borderRightWidth",
|
|
"borderRightColor",
|
|
"paddingTop",
|
|
"paddingBottom",
|
|
"paddingLeft",
|
|
"paddingRight",
|
|
"borderPadding",
|
|
),
|
|
)
|
|
|
|
style.leading = max(first.leading + first.leadingSpace, first.fontSize * 1.25)
|
|
style.bulletFontName = first.bulletFontName or first.fontName
|
|
style.bulletFontSize = first.fontSize
|
|
|
|
# Border handling for Paragraph
|
|
|
|
# Transfer the styles for each side of the border, *not* the whole
|
|
# border values that reportlab supports. We'll draw them ourselves in
|
|
# PmlParagraph.
|
|
|
|
# If no border color is given, the text color is used (XXX Tables!)
|
|
if (style.borderTopColor is None) and style.borderTopWidth:
|
|
style.borderTopColor = first.textColor
|
|
if (style.borderBottomColor is None) and style.borderBottomWidth:
|
|
style.borderBottomColor = first.textColor
|
|
if (style.borderLeftColor is None) and style.borderLeftWidth:
|
|
style.borderLeftColor = first.textColor
|
|
if (style.borderRightColor is None) and style.borderRightWidth:
|
|
style.borderRightColor = first.textColor
|
|
|
|
style.fontName = tt2ps(first.fontName, first.bold, first.italic)
|
|
|
|
return style
|
|
|
|
def addTOC(self) -> None:
|
|
if not self.node:
|
|
return
|
|
|
|
styles = []
|
|
for i in range(20):
|
|
self.node.attributes["class"] = "pdftoclevel%d" % i
|
|
self.cssAttr = parser.CSSCollect(self.node, self)
|
|
parser.CSS2Frag(
|
|
self,
|
|
{
|
|
"margin-top": 0,
|
|
"margin-bottom": 0,
|
|
"margin-left": 0,
|
|
"margin-right": 0,
|
|
},
|
|
isBlock=True,
|
|
)
|
|
pstyle = self.toParagraphStyle(self.frag)
|
|
styles.append(pstyle)
|
|
|
|
self.toc.levelStyles = styles
|
|
self.addStory(self.toc)
|
|
self.indexing_story = None
|
|
|
|
def addPageCount(self) -> None:
|
|
if not self.multiBuild:
|
|
self.indexing_story = PmlPageCount()
|
|
self.multiBuild = True
|
|
|
|
@staticmethod
|
|
def getPageCount(flow: Flowable) -> PageCountText:
|
|
pc = PageCountText()
|
|
pc.setFlowable(flow)
|
|
return pc
|
|
|
|
@staticmethod
|
|
def addPageNumber(flow):
|
|
pgnumber = PageNumberText()
|
|
pgnumber.setFlowable(flow)
|
|
return pgnumber
|
|
|
|
@staticmethod
|
|
def dumpPara(_frags, _style):
|
|
return
|
|
|
|
def addPara(self, *, force: bool = False) -> None:
|
|
force = force or self.force
|
|
self.force = False
|
|
|
|
# Cleanup the trail
|
|
reversed(self.fragList)
|
|
|
|
# Find maximum lead
|
|
maxLeading: int = 0
|
|
# fontSize = 0
|
|
for frag in self.fragList:
|
|
leading = getSize(frag.leadingSource, frag.fontSize) + frag.leadingSpace
|
|
maxLeading = max(leading, frag.fontSize + frag.leadingSpace, maxLeading)
|
|
frag.leading = leading
|
|
|
|
if force or (self.text.strip() and self.fragList):
|
|
# Update paragraph style by style of first fragment
|
|
first = self.fragBlock
|
|
style = self.toParagraphStyle(first)
|
|
# style.leading = first.leading + first.leadingSpace
|
|
if first.leadingSpace:
|
|
style.leading = maxLeading
|
|
else:
|
|
style.leading = (
|
|
getSize(first.leadingSource, first.fontSize) + first.leadingSpace
|
|
)
|
|
|
|
bulletText = copy.copy(first.bulletText)
|
|
first.bulletText = None
|
|
|
|
# Add paragraph to story
|
|
if force or len(self.fragAnchor + self.fragList) > 0:
|
|
# We need this empty fragment to work around problems in
|
|
# Reportlab paragraphs regarding backGround etc.
|
|
if self.fragList:
|
|
# Reset not only text but also page#, otherwise you might end up with duplicate rendered page#s
|
|
self.fragList.append(
|
|
self.fragList[-1].clone(
|
|
text="", pageNumber=False, pageCount=False
|
|
)
|
|
)
|
|
else:
|
|
blank = self.frag.clone()
|
|
blank.fontName = "Helvetica"
|
|
blank.text = ""
|
|
self.fragList.append(blank)
|
|
|
|
self.dumpPara(self.fragAnchor + self.fragList, style)
|
|
if hasattr(self, "language"):
|
|
language = self.__getattribute__("language")
|
|
detect_language_result = arabic_format(self.text, language)
|
|
if detect_language_result is not None:
|
|
self.text = detect_language_result
|
|
|
|
para = PmlParagraph(
|
|
self.text,
|
|
style,
|
|
frags=self.fragAnchor + self.fragList,
|
|
bulletText=bulletText,
|
|
dir=self.dir,
|
|
)
|
|
|
|
para.outline = first.outline
|
|
para.outlineLevel = first.outlineLevel
|
|
para.outlineOpen = first.outlineOpen
|
|
para.keepWithNext = first.keepWithNext
|
|
para.autoLeading = "max"
|
|
|
|
if self.image:
|
|
para = PmlParagraphAndImage(
|
|
para, self.image, side=self.imageData.get("align", "left")
|
|
)
|
|
|
|
self.addStory(para)
|
|
|
|
self.fragAnchor = []
|
|
first.bulletText = None
|
|
|
|
# Reset data
|
|
|
|
self.image = None
|
|
self.imageData = {}
|
|
|
|
self.clearFrag()
|
|
|
|
# METHODS FOR FRAG
|
|
def clearFrag(self) -> None:
|
|
self.fragList = []
|
|
self.fragStrip = True
|
|
self.text = ""
|
|
|
|
def copyFrag(self, **kw):
|
|
return self.frag.clone(**kw)
|
|
|
|
def newFrag(self, **kw):
|
|
self.frag = self.frag.clone(**kw)
|
|
return self.frag
|
|
|
|
def _appendFrag(self, frag) -> None:
|
|
if frag.link and frag.link.startswith("#"):
|
|
self.anchorFrag.append((frag, frag.link[1:]))
|
|
self.fragList.append(frag)
|
|
|
|
# XXX Argument frag is useless!
|
|
def addFrag(self, text="", frag=None):
|
|
frag = baseFrag = self.frag.clone()
|
|
|
|
# if sub and super are both on they will cancel each other out
|
|
if frag.sub == 1 and frag.super == 1:
|
|
frag.sub = 0
|
|
frag.super = 0
|
|
|
|
# XXX Has to be replaced by CSS styles like vertical-align and
|
|
# font-size
|
|
if frag.sub:
|
|
frag.rise = -frag.fontSize * subFraction
|
|
frag.fontSize = max(frag.fontSize - sizeDelta, 3)
|
|
elif frag.super:
|
|
frag.rise = frag.fontSize * superFraction
|
|
frag.fontSize = max(frag.fontSize - sizeDelta, 3)
|
|
|
|
# bold, italic, and underline
|
|
frag.fontName = frag.bulletFontName = tt2ps(
|
|
frag.fontName, frag.bold, frag.italic
|
|
)
|
|
if isinstance(text, (PageNumberText, PageCountText)):
|
|
frag.text = text
|
|
# self.text += frag.text
|
|
self._appendFrag(frag)
|
|
return
|
|
|
|
# Replace ­ with empty and normalize NBSP
|
|
text = text.replace("\xad", "").replace("\xc2\xa0", NBSP).replace("\xa0", NBSP)
|
|
|
|
if frag.whiteSpace == "pre":
|
|
# Handle by lines
|
|
for text in re.split(r"(\r\n|\n|\r)", text):
|
|
# This is an exceptionally expensive piece of code
|
|
self.text += text
|
|
if ("\n" in text) or ("\r" in text):
|
|
# If EOL insert a linebreak
|
|
frag = baseFrag.clone()
|
|
frag.text = ""
|
|
frag.lineBreak = 1
|
|
self._appendFrag(frag)
|
|
else:
|
|
# Handle tabs in a simple way
|
|
text = text.replace("\t", 8 * " ")
|
|
# Somehow for Reportlab NBSP have to be inserted
|
|
# as single character fragments
|
|
for text in re.split(r"(\ )", text):
|
|
frag = baseFrag.clone()
|
|
if text == " ":
|
|
text = NBSP
|
|
frag.text = text
|
|
self._appendFrag(frag)
|
|
else:
|
|
for text in re.split("(" + NBSP + ")", text):
|
|
frag = baseFrag.clone()
|
|
if text == NBSP:
|
|
self.force = True
|
|
frag.text = NBSP
|
|
self.text += text
|
|
self._appendFrag(frag)
|
|
else:
|
|
frag.text = " ".join(("x" + text + "x").split())[1:-1]
|
|
language_check = frag_text_language_check(self, frag.text)
|
|
if language_check:
|
|
frag.text = language_check
|
|
if self.fragStrip:
|
|
frag.text = frag.text.lstrip()
|
|
if frag.text:
|
|
self.fragStrip = False
|
|
self.text += frag.text
|
|
self._appendFrag(frag)
|
|
|
|
def pushFrag(self) -> None:
|
|
self.fragStack.append(self.frag)
|
|
self.newFrag()
|
|
|
|
def pullFrag(self) -> None:
|
|
self.frag = self.fragStack.pop()
|
|
|
|
# XXX
|
|
def _getFragment(self, line=20):
|
|
try:
|
|
return repr(" ".join(self.node.toxml().split()[:line]))
|
|
except Exception:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _getLineNumber() -> int:
|
|
return 0
|
|
|
|
def context(self, msg: str) -> str:
|
|
return f"{msg!s}\n{self._getFragment(50)}"
|
|
|
|
def warning(self, msg, *args):
|
|
self.warn += 1
|
|
self.log.append(
|
|
(
|
|
default.PML_WARNING,
|
|
self._getLineNumber(),
|
|
str(msg),
|
|
self._getFragment(50),
|
|
)
|
|
)
|
|
try:
|
|
return self.context(msg % args)
|
|
except Exception:
|
|
return self.context(msg)
|
|
|
|
def error(self, msg, *args):
|
|
self.err += 1
|
|
self.log.append(
|
|
(default.PML_ERROR, self._getLineNumber(), str(msg), self._getFragment(50))
|
|
)
|
|
try:
|
|
return self.context(msg % args)
|
|
except Exception:
|
|
return self.context(msg)
|
|
|
|
def getFile(self, name, relative=None) -> pisaFileObject | None:
|
|
"""Returns a file name or None."""
|
|
if name is None:
|
|
return None
|
|
return getFile(name, relative or self.pathDirectory, callback=self.pathCallback)
|
|
|
|
def getFontName(self, names, default="helvetica"):
|
|
"""Name of a font."""
|
|
# print names, self.fontList
|
|
if not isinstance(names, list):
|
|
names = str(names)
|
|
names = names.strip().split(",")
|
|
for name in names:
|
|
name = str(name)
|
|
font = name.strip().lower()
|
|
if font in self.asianFontList:
|
|
font = self.asianFontList.get(font, None)
|
|
set_asian_fonts(font)
|
|
else:
|
|
font = self.fontList.get(font, None)
|
|
if font is not None:
|
|
return font
|
|
return self.fontList.get(default, None)
|
|
|
|
def registerFont(self, fontname, alias=None):
|
|
alias = alias if alias is not None else []
|
|
self.fontList[str(fontname).lower()] = str(fontname)
|
|
for a in alias:
|
|
self.fontList[str(a)] = str(fontname)
|
|
|
|
# TODO: convert to getFile to support remotes fonts
|
|
def loadFont(self, names, src, encoding="WinAnsiEncoding", bold=0, italic=0):
|
|
# XXX Just works for local filenames!
|
|
if names and src:
|
|
file = src
|
|
src = file.uri
|
|
|
|
log.debug("Load font %r", src)
|
|
if isinstance(names, str) and names.startswith("#"):
|
|
names = names.strip("#")
|
|
if isinstance(names, list):
|
|
fontAlias = names
|
|
else:
|
|
fontAlias = (x.lower().strip() for x in names.split(",") if x)
|
|
|
|
# XXX Problems with unicode here
|
|
fontAlias = [str(x) for x in fontAlias]
|
|
|
|
fontName = fontAlias[0]
|
|
parts = src.split(".")
|
|
baseName, suffix = ".".join(parts[:-1]), parts[-1]
|
|
suffix = suffix.lower()
|
|
|
|
if suffix in ["ttc", "ttf"]:
|
|
# determine full font name according to weight and style
|
|
fullFontName = "%s_%d%d" % (fontName, bold, italic)
|
|
|
|
# check if font has already been registered
|
|
if fullFontName in self.fontList:
|
|
log.warning(
|
|
self.warning(
|
|
"Repeated font embed for %s, skip new embed ", fullFontName
|
|
)
|
|
)
|
|
else:
|
|
# Register TTF font and special name
|
|
filename = file.getNamedFile()
|
|
file = TTFont(fullFontName, filename)
|
|
pdfmetrics.registerFont(file)
|
|
|
|
# Add or replace missing styles
|
|
for bold in (0, 1):
|
|
for italic in (0, 1):
|
|
if (
|
|
"%s_%d%d" % (fontName, bold, italic)
|
|
) not in self.fontList:
|
|
addMapping(fontName, bold, italic, fullFontName)
|
|
|
|
# Register "normal" name and the place holder for style
|
|
self.registerFont(fontName, [*fontAlias, fullFontName])
|
|
|
|
elif suffix in ("afm", "pfb"):
|
|
if suffix == "afm":
|
|
afm = file.getNamedFile()
|
|
tfile = pisaFileObject(baseName + ".pfb", basepath=file.basepath)
|
|
pfb = tfile.getNamedFile()
|
|
else:
|
|
pfb = file.getNamedFile()
|
|
tfile = pisaFileObject(baseName + ".afm", basepath=file.basepath)
|
|
afm = tfile.getNamedFile()
|
|
|
|
# determine full font name according to weight and style
|
|
fullFontName = "%s_%d%d" % (fontName, bold, italic)
|
|
|
|
# check if font has already been registered
|
|
if fullFontName in self.fontList:
|
|
log.warning(
|
|
self.warning(
|
|
"Repeated font embed for %s, skip new embed", fontName
|
|
)
|
|
)
|
|
else:
|
|
# Include font
|
|
face = pdfmetrics.EmbeddedType1Face(afm, pfb)
|
|
fontNameOriginal = face.name
|
|
pdfmetrics.registerTypeFace(face)
|
|
# print fontName, fontNameOriginal, fullFontName
|
|
justFont = pdfmetrics.Font(fullFontName, fontNameOriginal, encoding)
|
|
pdfmetrics.registerFont(justFont)
|
|
|
|
# Add or replace missing styles
|
|
for bold in (0, 1):
|
|
for italic in (0, 1):
|
|
if (
|
|
"%s_%d%d" % (fontName, bold, italic)
|
|
) not in self.fontList:
|
|
addMapping(fontName, bold, italic, fontNameOriginal)
|
|
|
|
# Register "normal" name and the place holder for style
|
|
self.registerFont(
|
|
fontName, [*fontAlias, fullFontName, fontNameOriginal]
|
|
)
|
|
else:
|
|
log.warning(self.warning("wrong attributes for <pdf:font>"))
|