615 lines
21 KiB
Python
615 lines
21 KiB
Python
import math
|
|
from typing import NamedTuple, Sequence, List, NewType
|
|
|
|
from .errors import FPDFException
|
|
from .enums import Align, XPos, YPos, WrapMode
|
|
from .image_datastructures import VectorImageInfo
|
|
from .image_parsing import preload_image
|
|
from .line_break import MultiLineBreak, FORM_FEED
|
|
|
|
# Since Python doesn't have "friend classes"...
|
|
# pylint: disable=protected-access
|
|
|
|
|
|
class Extents(NamedTuple):
|
|
left: float
|
|
right: float
|
|
|
|
|
|
class TextRegionMixin:
|
|
"""Mix-in to be added to FPDF() in order to support text regions."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.clear_text_region()
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def register_text_region(self, region):
|
|
self.__current_text_region = region
|
|
|
|
def is_current_text_region(self, region):
|
|
return self.__current_text_region == region
|
|
|
|
def clear_text_region(self):
|
|
self.__current_text_region = None
|
|
|
|
|
|
# forward declaration for LineWrapper.
|
|
Paragraph = NewType("Paragraph", None)
|
|
|
|
|
|
class LineWrapper(NamedTuple):
|
|
"""Connects each TextLine with the Paragraph it was written to.
|
|
This allows to access paragraph specific attributes like
|
|
top/bottom margins when rendering the line.
|
|
"""
|
|
|
|
line: Sequence
|
|
paragraph: Paragraph
|
|
first_line: bool = False
|
|
last_line: bool = False
|
|
|
|
|
|
class Paragraph: # pylint: disable=function-redefined
|
|
def __init__(
|
|
self,
|
|
region,
|
|
text_align=None,
|
|
line_height=None,
|
|
top_margin: float = 0,
|
|
bottom_margin: float = 0,
|
|
skip_leading_spaces: bool = False,
|
|
wrapmode: WrapMode = None,
|
|
):
|
|
self._region = region
|
|
self.pdf = region.pdf
|
|
if text_align:
|
|
text_align = Align.coerce(text_align)
|
|
if text_align not in (Align.L, Align.C, Align.R, Align.J):
|
|
raise ValueError(
|
|
f"Text_align must be 'LEFT', 'CENTER', 'RIGHT', or 'JUSTIFY', not '{text_align.value}'."
|
|
)
|
|
self.text_align = text_align
|
|
if line_height is None:
|
|
self.line_height = region.line_height
|
|
else:
|
|
self.line_height = line_height
|
|
self.top_margin = top_margin
|
|
self.bottom_margin = bottom_margin
|
|
self.skip_leading_spaces = skip_leading_spaces
|
|
if wrapmode is None:
|
|
self.wrapmode = self._region.wrapmode
|
|
else:
|
|
self.wrapmode = WrapMode.coerce(wrapmode)
|
|
self._text_fragments = []
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
self._region.end_paragraph()
|
|
|
|
def write(self, text: str, link=None):
|
|
if not self.pdf.font_family:
|
|
raise FPDFException("No font set, you need to call set_font() beforehand")
|
|
normalized_string = self.pdf.normalize_text(text).replace("\r", "")
|
|
# YYY _preload_font_styles() should accept a "link" argument.
|
|
fragments = self.pdf._preload_font_styles(normalized_string, markdown=False)
|
|
if link:
|
|
for frag in fragments:
|
|
frag.link = link
|
|
self._text_fragments.extend(fragments)
|
|
|
|
def ln(self, h=None):
|
|
if not self.pdf.font_family:
|
|
raise FPDFException("No font set, you need to call set_font() beforehand")
|
|
if h is None:
|
|
h = self.pdf.font_size * self.line_height
|
|
fragment = self.pdf._preload_font_styles("\n", markdown=False)[0]
|
|
fragment.graphics_state["font_size_pt"] = h * fragment.k
|
|
self._text_fragments.append(fragment)
|
|
|
|
def build_lines(self, print_sh) -> List[LineWrapper]:
|
|
text_lines = []
|
|
multi_line_break = MultiLineBreak(
|
|
self._text_fragments,
|
|
max_width=self._region.get_width,
|
|
margins=(self.pdf.c_margin, self.pdf.c_margin),
|
|
align=self.text_align or self._region.text_align or Align.L,
|
|
print_sh=print_sh,
|
|
wrapmode=self.wrapmode,
|
|
line_height=self.line_height,
|
|
skip_leading_spaces=self.skip_leading_spaces
|
|
or self._region.skip_leading_spaces,
|
|
)
|
|
self._text_fragments = []
|
|
text_line = multi_line_break.get_line()
|
|
first_line = True
|
|
while (text_line) is not None:
|
|
text_lines.append(LineWrapper(text_line, self, first_line=first_line))
|
|
first_line = False
|
|
text_line = multi_line_break.get_line()
|
|
if text_lines:
|
|
last = text_lines[-1]
|
|
last = LineWrapper(
|
|
last.line, self, first_line=last.first_line, last_line=True
|
|
)
|
|
text_lines[-1] = last
|
|
return text_lines
|
|
|
|
|
|
class ImageParagraph:
|
|
def __init__(
|
|
self,
|
|
region,
|
|
name,
|
|
align=None,
|
|
width: float = None,
|
|
height: float = None,
|
|
fill_width: bool = False,
|
|
keep_aspect_ratio=False,
|
|
top_margin=0,
|
|
bottom_margin=0,
|
|
link=None,
|
|
title=None,
|
|
alt_text=None,
|
|
):
|
|
self.region = region
|
|
self.name = name
|
|
if align:
|
|
align = Align.coerce(align)
|
|
if align not in (Align.L, Align.C, Align.R):
|
|
raise ValueError(
|
|
f"Align must be 'LEFT', 'CENTER', or 'RIGHT', not '{align.value}'."
|
|
)
|
|
self.align = align
|
|
self.width = width
|
|
self.height = height
|
|
self.fill_width = fill_width
|
|
self.keep_aspect_ratio = keep_aspect_ratio
|
|
self.top_margin = top_margin
|
|
self.bottom_margin = bottom_margin
|
|
self.link = link
|
|
self.title = title
|
|
self.alt_text = alt_text
|
|
self.img = self.info = None
|
|
|
|
def build_line(self):
|
|
# We do double duty as a "text line wrapper" here, since all the necessary
|
|
# information is already in the ImageParagraph object.
|
|
self.name, self.img, self.info = preload_image(
|
|
self.region.pdf.image_cache, self.name
|
|
)
|
|
return self
|
|
|
|
def render(self, col_left, col_width, max_height):
|
|
if not self.img:
|
|
raise RuntimeError(
|
|
"ImageParagraph.build_line() must be called before render()."
|
|
)
|
|
is_svg = isinstance(self.info, VectorImageInfo)
|
|
if self.height:
|
|
h = self.height
|
|
else:
|
|
native_h = self.info["h"] / self.region.pdf.k
|
|
if self.width:
|
|
w = self.width
|
|
else:
|
|
native_w = self.info["w"] / self.region.pdf.k
|
|
if native_w > col_width or self.fill_width:
|
|
w = col_width
|
|
else:
|
|
w = native_w
|
|
if not self.height:
|
|
h = w * native_h / native_w
|
|
if h > max_height:
|
|
return None
|
|
x = col_left
|
|
if self.align:
|
|
if self.align == Align.R:
|
|
x += col_width - w
|
|
elif self.align == Align.C:
|
|
x += (col_width - w) / 2
|
|
if is_svg:
|
|
return self.region.pdf._vector_image(
|
|
svg=self.img,
|
|
info=self.info,
|
|
x=x,
|
|
y=None,
|
|
w=w,
|
|
h=h,
|
|
link=self.link,
|
|
title=self.title,
|
|
alt_text=self.alt_text,
|
|
keep_aspect_ratio=self.keep_aspect_ratio,
|
|
)
|
|
return self.region.pdf._raster_image(
|
|
name=self.name,
|
|
img=self.img,
|
|
info=self.info,
|
|
x=x,
|
|
y=None,
|
|
w=w,
|
|
h=h,
|
|
link=self.link,
|
|
title=self.title,
|
|
alt_text=self.alt_text,
|
|
dims=None,
|
|
keep_aspect_ratio=self.keep_aspect_ratio,
|
|
)
|
|
|
|
|
|
class ParagraphCollectorMixin:
|
|
def __init__(
|
|
self,
|
|
pdf,
|
|
*args,
|
|
text=None,
|
|
text_align="LEFT",
|
|
line_height: float = 1.0,
|
|
print_sh: bool = False,
|
|
skip_leading_spaces: bool = False,
|
|
wrapmode: WrapMode = None,
|
|
img=None,
|
|
img_fill_width=False,
|
|
**kwargs,
|
|
):
|
|
self.pdf = pdf
|
|
self.text_align = Align.coerce(text_align) # default for auto paragraphs
|
|
if self.text_align not in (Align.L, Align.C, Align.R, Align.J):
|
|
raise ValueError(
|
|
f"Text_align must be 'LEFT', 'CENTER', 'RIGHT', or 'JUSTIFY', not '{self.text_align.value}'."
|
|
)
|
|
self.line_height = line_height
|
|
self.print_sh = print_sh
|
|
self.wrapmode = WrapMode.coerce(wrapmode)
|
|
self.skip_leading_spaces = skip_leading_spaces
|
|
self._paragraphs = []
|
|
self._active_paragraph = None
|
|
super().__init__(pdf, *args, **kwargs)
|
|
if text:
|
|
self.write(text)
|
|
if img:
|
|
self.image(img, fill_width=img_fill_width)
|
|
|
|
def __enter__(self):
|
|
if self.pdf.is_current_text_region(self):
|
|
raise FPDFException(
|
|
f"Unable to enter the same {self.__class__.__name__} context recursively."
|
|
)
|
|
self._page = self.pdf.page
|
|
self.pdf._push_local_stack()
|
|
self.pdf.page = 0
|
|
self.pdf.register_text_region(self)
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
self.pdf.clear_text_region()
|
|
self.pdf.page = self._page
|
|
self.pdf._pop_local_stack()
|
|
self.render()
|
|
|
|
def _check_paragraph(self):
|
|
if self._active_paragraph == "EXPLICIT":
|
|
raise FPDFException(
|
|
"Conflicts with active paragraph. Either close the current paragraph or write your text inside it."
|
|
)
|
|
if self._active_paragraph is None:
|
|
p = Paragraph(
|
|
region=self,
|
|
text_align=self.text_align,
|
|
skip_leading_spaces=self.skip_leading_spaces,
|
|
)
|
|
self._paragraphs.append(p)
|
|
self._active_paragraph = "AUTO"
|
|
|
|
def write(self, text: str, link=None): # pylint: disable=unused-argument
|
|
self._check_paragraph()
|
|
self._paragraphs[-1].write(text)
|
|
|
|
def ln(self, h=None):
|
|
self._check_paragraph()
|
|
self._paragraphs[-1].ln(h)
|
|
|
|
def paragraph(
|
|
self,
|
|
text_align=None,
|
|
line_height=None,
|
|
skip_leading_spaces: bool = False,
|
|
top_margin=0,
|
|
bottom_margin=0,
|
|
wrapmode: WrapMode = None,
|
|
):
|
|
if self._active_paragraph == "EXPLICIT":
|
|
raise FPDFException("Unable to nest paragraphs.")
|
|
p = Paragraph(
|
|
region=self,
|
|
text_align=text_align or self.text_align,
|
|
line_height=line_height,
|
|
skip_leading_spaces=skip_leading_spaces or self.skip_leading_spaces,
|
|
wrapmode=wrapmode,
|
|
top_margin=top_margin,
|
|
bottom_margin=bottom_margin,
|
|
)
|
|
self._paragraphs.append(p)
|
|
self._active_paragraph = "EXPLICIT"
|
|
return p
|
|
|
|
def end_paragraph(self):
|
|
if not self._active_paragraph:
|
|
raise FPDFException("No active paragraph to end.")
|
|
# self._paragraphs[-1].write("\n")
|
|
self._active_paragraph = None
|
|
|
|
def image(
|
|
self,
|
|
name,
|
|
align=None,
|
|
width: float = None,
|
|
height: float = None,
|
|
fill_width: bool = False,
|
|
keep_aspect_ratio=False,
|
|
top_margin=0,
|
|
bottom_margin=0,
|
|
link=None,
|
|
title=None,
|
|
alt_text=None,
|
|
):
|
|
if self._active_paragraph == "EXPLICIT":
|
|
raise FPDFException("Unable to nest paragraphs.")
|
|
if self._active_paragraph:
|
|
self.end_paragraph()
|
|
p = ImageParagraph(
|
|
self,
|
|
name,
|
|
align=align,
|
|
width=width,
|
|
height=height,
|
|
fill_width=fill_width,
|
|
keep_aspect_ratio=keep_aspect_ratio,
|
|
top_margin=top_margin,
|
|
bottom_margin=bottom_margin,
|
|
link=link,
|
|
title=title,
|
|
alt_text=alt_text,
|
|
)
|
|
self._paragraphs.append(p)
|
|
|
|
|
|
class TextRegion(ParagraphCollectorMixin):
|
|
"""Abstract base class for all text region subclasses."""
|
|
|
|
def current_x_extents(self, y, height):
|
|
"""
|
|
Return the horizontal extents of the current line.
|
|
Columnar regions simply return the boundaries of the column.
|
|
Regions with non-vertical boundaries need to check how the largest
|
|
font-height in the current line actually fits in there.
|
|
For that reason we include the current y and the line height.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def _render_image_paragraph(self, paragraph):
|
|
if paragraph.top_margin and self.pdf.y > self.pdf.t_margin:
|
|
self.pdf.y += paragraph.top_margin
|
|
col_left, col_right = self.current_x_extents(self.pdf.y, 0)
|
|
bottom = self.pdf.h - self.pdf.b_margin
|
|
max_height = bottom - self.pdf.y
|
|
rendered = paragraph.render(col_left, col_right - col_left, max_height)
|
|
if rendered:
|
|
margin = paragraph.bottom_margin
|
|
if margin and (self.pdf.y + margin) < bottom:
|
|
self.pdf.y += margin
|
|
return rendered
|
|
|
|
def _render_column_lines(self, text_lines, top, bottom):
|
|
if not text_lines:
|
|
return 0 # no rendered height
|
|
self.pdf.y = top
|
|
prev_line_height = 0
|
|
last_line_height = None
|
|
rendered_lines = 0
|
|
for tl_wrapper in text_lines:
|
|
if isinstance(tl_wrapper, ImageParagraph):
|
|
if self._render_image_paragraph(tl_wrapper):
|
|
rendered_lines += 1
|
|
else: # not enough room for image
|
|
break
|
|
else:
|
|
text_line = tl_wrapper.line
|
|
text_rendered = False
|
|
for frag in text_line.fragments:
|
|
if frag.characters:
|
|
text_rendered = True
|
|
break
|
|
if (
|
|
text_rendered
|
|
and tl_wrapper.first_line
|
|
and tl_wrapper.paragraph.top_margin
|
|
and self.pdf.y > self.pdf.t_margin
|
|
):
|
|
self.pdf.y += tl_wrapper.paragraph.top_margin
|
|
else:
|
|
if self.pdf.y + text_line.height > bottom:
|
|
last_line_height = prev_line_height
|
|
break
|
|
prev_line_height = last_line_height
|
|
last_line_height = text_line.height
|
|
col_left, col_right = self.current_x_extents(self.pdf.y, 0)
|
|
if self.pdf.x < col_left or self.pdf.x >= col_right:
|
|
self.pdf.x = col_left
|
|
# Don't check the return, we never render past the bottom here.
|
|
self.pdf._render_styled_text_line(
|
|
text_line,
|
|
h=text_line.height,
|
|
border=0,
|
|
new_x=XPos.LEFT,
|
|
new_y=YPos.NEXT,
|
|
fill=False,
|
|
)
|
|
if tl_wrapper.last_line:
|
|
margin = tl_wrapper.paragraph.bottom_margin
|
|
if margin and text_rendered and (self.pdf.y + margin) < bottom:
|
|
self.pdf.y += tl_wrapper.paragraph.bottom_margin
|
|
rendered_lines += 1
|
|
if text_line.trailing_form_feed: # column break
|
|
break
|
|
if rendered_lines:
|
|
del text_lines[:rendered_lines]
|
|
return last_line_height
|
|
|
|
def _render_lines(self, text_lines, top, bottom):
|
|
"""Default page rendering a set of lines in one column"""
|
|
if text_lines:
|
|
self._render_column_lines(text_lines, top, bottom)
|
|
|
|
def collect_lines(self):
|
|
text_lines = []
|
|
for paragraph in self._paragraphs:
|
|
if isinstance(paragraph, ImageParagraph):
|
|
line = paragraph.build_line()
|
|
text_lines.append(line)
|
|
else:
|
|
cur_lines = paragraph.build_lines(self.print_sh)
|
|
if not cur_lines:
|
|
continue
|
|
text_lines.extend(cur_lines)
|
|
return text_lines
|
|
|
|
def render(self):
|
|
raise NotImplementedError()
|
|
|
|
def get_width(self, height):
|
|
start, end = self.current_x_extents(self.pdf.y, height)
|
|
if self.pdf.x > start and self.pdf.x < end:
|
|
start = self.pdf.x
|
|
res = end - start
|
|
return res
|
|
|
|
|
|
class TextColumnarMixin:
|
|
"""Enable a TextRegion to perform page breaks"""
|
|
|
|
def __init__(self, pdf, *args, l_margin=None, r_margin=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.l_margin = pdf.l_margin if l_margin is None else l_margin
|
|
left = self.l_margin
|
|
self.r_margin = pdf.r_margin if r_margin is None else r_margin
|
|
right = pdf.w - self.r_margin
|
|
self._set_left_right(left, right)
|
|
|
|
def _set_left_right(self, left, right):
|
|
left = self.pdf.l_margin if left is None else left
|
|
right = (self.pdf.w - self.pdf.r_margin) if right is None else right
|
|
if right <= left:
|
|
raise FPDFException(
|
|
f"{self.__class__.__name__}(): "
|
|
f"Right limit ({right}) lower than left limit ({left})."
|
|
)
|
|
self.extents = Extents(left, right)
|
|
|
|
|
|
class TextColumns(TextRegion, TextColumnarMixin):
|
|
def __init__(
|
|
self,
|
|
pdf,
|
|
*args,
|
|
ncols: int = 1,
|
|
gutter: float = 10,
|
|
balance: bool = False,
|
|
**kwargs,
|
|
):
|
|
super().__init__(pdf, *args, **kwargs)
|
|
self._cur_column = 0
|
|
self._ncols = ncols
|
|
self.balance = balance
|
|
total_w = self.extents.right - self.extents.left
|
|
col_width = (total_w - (ncols - 1) * gutter) / ncols
|
|
# We calculate the column extents once in advance, and store them for lookup.
|
|
c_left = self.extents.left
|
|
self._cols = [Extents(c_left, c_left + col_width)]
|
|
for i in range(1, ncols): # pylint: disable=unused-variable
|
|
c_left += col_width + gutter
|
|
self._cols.append(Extents(c_left, c_left + col_width))
|
|
self._first_page_top = max(self.pdf.t_margin, self.pdf.y)
|
|
|
|
def __enter__(self):
|
|
super().__enter__()
|
|
self._first_page_top = max(self.pdf.t_margin, self.pdf.y)
|
|
if self.balance:
|
|
self._cur_column = 0
|
|
self.pdf.x = self._cols[self._cur_column].left
|
|
return self
|
|
|
|
def new_column(self):
|
|
if self._paragraphs:
|
|
self._paragraphs[-1].write(FORM_FEED)
|
|
else:
|
|
self.write(FORM_FEED)
|
|
|
|
def _render_page_lines(self, text_lines, top, bottom):
|
|
"""Rendering a set of lines in one or several columns on one page."""
|
|
balancing = False
|
|
next_y = self.pdf.y
|
|
if self.balance:
|
|
# Column balancing is currently very simplistic, and only works reliably when
|
|
# line height doesn't change much within the text block.
|
|
# The "correct" solution would require an exact precalculation of the hight of
|
|
# each column with the specific line heights and iterative regrouping of lines,
|
|
# which seems excessive at this point.
|
|
# Contribution of a more reliable but still reasonably simple algorithm welcome.
|
|
page_bottom = bottom
|
|
if not text_lines:
|
|
return
|
|
tot_height = sum(l.line.height for l in text_lines)
|
|
col_height = tot_height / self._ncols
|
|
avail_height = bottom - top
|
|
if col_height < avail_height:
|
|
balancing = True # We actually have room to balance on this page.
|
|
# total height divided by n
|
|
bottom = top + col_height
|
|
# A bit more generous: Try to keep the rightmost column the shortest.
|
|
lines_per_column = math.ceil(len(text_lines) / self._ncols) + 0.5
|
|
mult_height = text_lines[0].line.height * lines_per_column
|
|
if mult_height > col_height:
|
|
bottom = top + mult_height
|
|
if bottom > page_bottom:
|
|
# Turns out we don't actually have enough room.
|
|
bottom = page_bottom
|
|
balancing = False
|
|
for c in range(self._cur_column, self._ncols):
|
|
if not text_lines:
|
|
return
|
|
if c != self._cur_column:
|
|
self._cur_column = c
|
|
col_left, col_right = self.current_x_extents(0, 0)
|
|
if self.pdf.x < col_left or self.pdf.x >= col_right:
|
|
self.pdf.x = col_left
|
|
if balancing and c == (self._ncols - 1):
|
|
# Give the last column more space in case the balancing is out of whack.
|
|
bottom = self.pdf.h - self.pdf.b_margin
|
|
last_line_height = self._render_column_lines(text_lines, top, bottom)
|
|
if balancing:
|
|
new_y = self.pdf.y + last_line_height
|
|
if new_y > next_y:
|
|
next_y = new_y
|
|
if balancing:
|
|
self.pdf.y = next_y
|
|
|
|
def render(self):
|
|
if not self._paragraphs:
|
|
return
|
|
text_lines = self.collect_lines()
|
|
if not text_lines:
|
|
return
|
|
page_bottom = self.pdf.h - self.pdf.b_margin
|
|
_first_page_top = max(self.pdf.t_margin, self.pdf.y)
|
|
self._render_page_lines(text_lines, _first_page_top, page_bottom)
|
|
while text_lines:
|
|
self.pdf.add_page(same=True)
|
|
self._cur_column = 0
|
|
self._render_page_lines(text_lines, self.pdf.y, page_bottom)
|
|
|
|
def current_x_extents(self, y, height):
|
|
left, right = self._cols[self._cur_column]
|
|
return left, right
|