at the beginning of the PDF. Default to False.
"""
super().__init__()
self.pdf = pdf
self.ul_bullet_char = ul_bullet_char
self.image_map = image_map
self.li_prefix_color = (
color_as_decimal(li_prefix_color)
if isinstance(li_prefix_color, str)
else convert_to_device_color(li_prefix_color)
)
self.warn_on_tags_not_matching = warn_on_tags_not_matching
# The following 4 attributes are there to serve as "temporary state",
# so that changes to those settings are saved,
# but not reflected onto self.pdf yet,
# and only "effectively" applied when self._write_paragraph() is called.
# This way, we often avoid useless operators in the PDF content stream.
self.font_family: str = pdf.font_family or font_family
self.font_size_pt: float = pdf.font_size_pt
self.font_emphasis = TextEmphasis.NONE
self.font_color = pdf.text_color
# For historical / backward-compatibility reasons,
# write_html() sets an active font (Times by default):
self.pdf.set_font(
family=self.font_family,
size=self.font_size_pt,
style=self.font_emphasis.style,
)
self.style_stack: list[FontFace] = [] # list of FontFace
self._page_break_after_paragraph = False
self.follows_trailing_space = False # The last write has ended with a space.
self.follows_heading = False # We don't want extra space below a heading.
self.align: Optional[Union[float, Align]] = None
self.heading_level: Optional[int] = None
self._tags_stack: list[str] = []
self._column = self.pdf.text_columns(skip_leading_spaces=True)
self._paragraph: Optional["Paragraph"] = self._column.paragraph()
# -related properties:
self._pre_formatted = False # preserve whitespace while True.
# nothing written yet to , remove one initial nl:
self._pre_started = False
# -related properties:
self.href: int | str = ""
# /-related properties:
self.indent = 0
self.line_height_stack: list[float | None] = []
self.ol_type: dict[int, str] = (
{}
) # when inside a tag, can be "a", "A", "i", "I" or "1"
self.bullet: list[int | str] = []
# -related properties:
self.render_title_tag = render_title_tag
self._in_title = False
# -related properties:
self.table_line_separators = table_line_separators
self.table: Optional[Table] = (
None # becomes a Table instance when processing tags
)
self.table_row: Optional[Row] = (
None # becomes a Row instance when processing tags
)
self.tr: Optional[dict[str, Optional[str]]] = (
None # becomes a dict of attributes when processing
tags
)
self.td_th: Optional[dict[str, Any]] = (
None # becomes a dict of attributes when processing | / | tags
)
# "inserted" is a special attribute indicating that a cell has be inserted in self.table_row
self.tag_styles = _scale_units(pdf, DEFAULT_TAG_STYLES)
for tag, tag_style in (tag_styles or {}).items():
if tag not in DEFAULT_TAG_STYLES:
raise NotImplementedError(
f"Cannot set style for HTML tag <{tag}> (contributions are welcome to add support for this)"
)
if not isinstance(tag_style, FontFace):
raise ValueError(
f"tag_styles values must be instances of FontFace or TextStyle - received: {tag_style}"
)
# We convert FontFace values provided for block tags into TextStyle values:
if tag in BLOCK_TAGS and not isinstance(tag_style, TextStyle):
# pylint: disable=redefined-loop-name
tag_style = TextStyle(
font_family=tag_style.family,
font_style=(
"" if not tag_style.emphasis else tag_style.emphasis.style
),
font_size_pt=tag_style.size_pt,
color=tag_style.color,
fill_color=tag_style.fill_color,
# Using default tag margins:
t_margin=self.tag_styles[
tag
].t_margin, # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownArgumentType]
l_margin=self.tag_styles[
tag
].l_margin, # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownArgumentType]
b_margin=self.tag_styles[
tag
].b_margin, # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownArgumentType]
)
self.tag_styles[tag] = tag_style
if heading_sizes is not None:
warnings.warn(
(
"The heading_sizes parameter is deprecated since v2.7.9 "
"and will be removed in a future release. "
"Set the `tag_styles` parameter instead."
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
for tag, size in heading_sizes.items():
self.tag_styles[tag] = self.tag_styles[tag].replace(font_size_pt=size)
if pre_code_font is not None:
warnings.warn(
(
"The pre_code_font parameter is deprecated since v2.7.9 "
"and will be removed in a future release. "
"Set the `tag_styles` parameter instead."
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
code_style = self.tag_styles["code"]
pre_style = self.tag_styles["pre"]
if isinstance(code_style, TextStyle):
code_style = code_style.replace(font_family=pre_code_font)
else:
code_style = code_style.replace(family=pre_code_font)
if isinstance(pre_style, TextStyle):
pre_style = pre_style.replace(font_family=pre_code_font)
else:
pre_style = pre_style.replace(family=pre_code_font)
self.tag_styles["code"] = code_style
self.tag_styles["pre"] = pre_style
if dd_tag_indent is not None:
warnings.warn(
(
"The dd_tag_indent parameter is deprecated since v2.7.9 "
"and will be removed in a future release. "
"Set the `tag_styles` parameter instead."
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
self.tag_styles["dd"] = self.tag_styles["dd"].replace(
l_margin=dd_tag_indent
)
if li_tag_indent is not None:
warnings.warn(
(
"The li_tag_indent parameter is deprecated since v2.7.9 "
"and will be removed in a future release. "
"Set the `tag_styles` parameter instead."
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
self.tag_styles["li"] = self.tag_styles["li"].replace(
l_margin=li_tag_indent
)
if tag_indents:
warnings.warn(
(
"The tag_indents parameter is deprecated since v2.8.0 "
"and will be removed in a future release. "
"Set the `tag_styles` parameter instead."
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
for tag, indent in tag_indents.items():
if tag not in self.tag_styles:
raise NotImplementedError(
f"Cannot set style for HTML tag <{tag}> (contributions are welcome to add support for this)"
)
self.tag_styles[tag] = self.tag_styles[tag].replace(l_margin=indent)
@staticmethod
def _normalize_l_margin(l_margin: Any) -> Union[float, Align]:
if isinstance(l_margin, Align):
return l_margin
if isinstance(l_margin, (int, float)):
return float(l_margin)
return 0.0
def _new_paragraph(
self,
align: Optional[Union[float, Align]] = None,
line_height: Optional[float] = 1.0,
top_margin: float = 0,
bottom_margin: float = 0,
indent: Union[float, Align] = 0,
bullet: str = "",
) -> None:
# Note that currently top_margin is ignored if bullet is also provided,
# due to the behaviour of TextRegion._render_column_lines()
self._end_paragraph()
self.align = align
if isinstance(indent, Align):
# Explicit alignment takes priority over alignment provided as TextStyle.l_margin:
if not self.align:
self.align = indent
indent = 0
if not top_margin and not self.follows_heading:
top_margin = self.font_size_pt / self.pdf.k
self._paragraph = self._column.paragraph(
text_align=self.align if isinstance(self.align, Align) else None,
line_height=line_height,
skip_leading_spaces=True,
top_margin=top_margin,
bottom_margin=bottom_margin,
indent=indent,
bullet_string=bullet,
)
self.follows_trailing_space = True
self.follows_heading = False
def _end_paragraph(self) -> None:
self.align = None
if not self._paragraph:
return
self._column.end_paragraph()
self._column.render()
self._paragraph = None
self.follows_trailing_space = True
if self._page_break_after_paragraph:
# pylint: disable=protected-access
self.pdf._perform_page_break() # pyright: ignore[reportPrivateUsage]
self._page_break_after_paragraph = False
def _write_paragraph(self, text: str, link: Optional[int | str] = None) -> None:
if not text:
return
if not self._paragraph:
self._new_paragraph()
# The following local stack is required
# in order for FPDF._get_current_graphics_state()
# to properly capture the current graphics state,
# and then to be able to drop those temporary changes,
# because they will only be "effectively" applied in .end_paragraph().
# pylint: disable=protected-access
self.pdf._push_local_stack() # pyright: ignore[reportPrivateUsage]
prev_page = self.pdf.page
self.pdf.page = 0
self.pdf.set_font(
family=self.font_family,
size=self.font_size_pt,
style=self.font_emphasis.style,
)
if self.font_color != self.pdf.text_color and self.font_color is not None:
self.pdf.set_text_color(self.font_color)
assert self._paragraph is not None
self._paragraph.write(text, link=link)
self.pdf.page = prev_page
self.pdf._pop_local_stack() # pyright: ignore[reportPrivateUsage]
def _ln(self, h: Optional[float] = None) -> None:
if self._paragraph:
self._paragraph.ln(h=h)
else:
self._column.ln(h=h)
self.follows_trailing_space = True
def handle_data(self, data: str) -> None:
if self._in_title:
if self.pdf.title:
LOGGER.warning('Ignoring repeated "%s"', data)
else:
self.pdf.set_title(data)
if not self.render_title_tag:
return
if self.td_th is not None:
data = data.strip()
if not data:
return
if "inserted" in self.td_th:
td_th_tag = self.td_th["tag"]
raise NotImplementedError(
f"Unsupported nested HTML tags inside <{td_th_tag}> element: <{self._tags_stack[-1]}>"
)
# We could potentially support nested / / tags
# by building a list of Fragment instances from the HTML cell content
# and then passing those fragments to Row.cell().
# However there should be an incoming refactoring of this code
# dedicated to text layout, and we should probably wait for that
# before supporting this feature.
align = self.td_th.get("align")
if align is None and self.tr is not None:
align = self.tr.get("align")
if align:
align = align.upper()
bgcolor_str = self.td_th.get("bgcolor")
if bgcolor_str is None and self.tr is not None:
bgcolor_str = self.tr.get("bgcolor")
bgcolor = color_as_decimal(bgcolor_str)
colspan = int(self.td_th.get("colspan") or "1")
rowspan = int(self.td_th.get("rowspan") or "1")
emphasis = 0
if self.td_th.get("b"):
emphasis |= TextEmphasis.B
if self.td_th.get("i"):
emphasis |= TextEmphasis.I
if self.td_th.get("U"):
emphasis |= TextEmphasis.U
font_family = (
self.font_family if self.font_family != self.pdf.font_family else None
)
font_size_pt = (
self.font_size_pt
if self.font_size_pt != self.pdf.font_size_pt
else None
)
font_style = None
if font_family or emphasis or font_size_pt or bgcolor:
font_style = FontFace(
family=font_family,
emphasis=emphasis,
size_pt=font_size_pt,
color=self.pdf.text_color,
fill_color=bgcolor,
)
assert self.table_row is not None
self.table_row.cell(
text=data,
align=align,
style=font_style,
colspan=colspan,
rowspan=rowspan,
)
self.td_th["inserted"] = True
elif self.table is not None:
# ignore anything else than td inside a table
pass
elif self._pre_formatted: # pre blocks
# If we want to mimic the exact HTML semantics about newlines at the
# beginning and end of the block, then this needs some more thought.
if data.startswith("\n") and self._pre_started:
if data.endswith("\n"):
data = data[1:-1]
else:
data = data[1:]
self._pre_started = False
self._write_data(data)
else:
data = _WS_SUB_PAT.sub(" ", data)
if self.follows_trailing_space and data[0] == " ":
self._write_data(data[1:])
else:
self._write_data(data)
self.follows_trailing_space = data[-1] == " "
if self._page_break_after_paragraph:
self._end_paragraph()
def _write_data(self, data: str) -> None:
if self.href:
self.put_link(data)
else:
if self.heading_level:
if self.pdf.section_title_styles:
raise NotImplementedError(
"Combining write_html() & section styles is currently not supported."
" You can open up an issue on github.com/py-pdf/fpdf2 if this is something you would like to see implemented."
)
self.pdf.start_section(data, self.heading_level - 1, strict=False)
self._write_paragraph(data)
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._pre_started = False
attrs_dict: dict[str, str | None] = dict(attrs)
del attrs
css_style = parse_css_style(attrs_dict.get("style") or "")
self._tags_stack.append(tag)
if css_style.get("break-before") == "page":
self._end_paragraph()
# pylint: disable=protected-access
self.pdf._perform_page_break() # pyright: ignore[reportPrivateUsage]
if tag in ("b", "i", "u") and self.td_th is not None:
self.td_th[tag] = True
if tag == "a":
self.href = attrs_dict["href"] or ""
try:
page = int(self.href)
self.href = self.pdf.add_link(page=page)
except ValueError:
pass
if tag == "br":
self._write_paragraph("\n")
if tag == "hr":
self._end_paragraph()
width_str = css_style.get("width", attrs_dict.get("width"))
if width_str:
if width_str[-1] == "%":
hr_width = self.pdf.epw * float(width_str[:-1]) / 100
else:
hr_width = float(width_str) / self.pdf.k
else:
hr_width = self.pdf.epw
# Centering:
x_start = self.pdf.l_margin + (self.pdf.epw - hr_width) / 2
self.pdf.line(
x1=x_start,
y1=self.pdf.y,
x2=x_start + hr_width,
y2=self.pdf.y,
)
self._write_paragraph("\n")
if tag == "p":
self.style_stack.append(
FontFace(
family=self.font_family,
emphasis=self.font_emphasis,
size_pt=self.font_size_pt,
color=self.font_color,
)
)
align: Optional[Align] = None
if "align" in attrs_dict:
try:
align_str = attrs_dict.get("align")
align = Align.coerce(align_str) if align_str is not None else None
except ValueError:
align = None
line_height_str = css_style.get(
"line-height", attrs_dict.get("line-height")
)
# "line-height" attributes are not valid in HTML,
# but we support it for backward compatibility,
# because fpdf2 honors it since 2.6.1 and PR #629
line_height: Optional[float] = None
if line_height_str:
try:
# YYY parse and convert non-float line_height values
line_height = float(line_height_str)
except ValueError:
line_height = None
tag_style = self.tag_styles[tag]
if tag_style.color is not None and tag_style.color != self.font_color:
self.font_color = tag_style.color
if tag_style.family is not None and tag_style.family != self.font_family:
self.font_family = tag_style.family
if tag_style.size_pt is not None and tag_style.size_pt != self.font_size_pt:
self.font_size_pt = tag_style.size_pt
if tag_style.emphasis:
self.font_emphasis |= tag_style.emphasis
if isinstance(tag_style, TextStyle):
t_margin = tag_style.t_margin
b_margin = tag_style.b_margin
l_margin = tag_style.l_margin
else:
t_margin = b_margin = l_margin = 0.0
l_margin = self._normalize_l_margin(l_margin)
self._new_paragraph(
align=align,
line_height=line_height or 1.0,
top_margin=t_margin,
bottom_margin=b_margin,
indent=l_margin,
)
if tag in HEADING_TAGS:
self.style_stack.append(
FontFace(
family=self.font_family,
emphasis=self.font_emphasis,
size_pt=self.font_size_pt,
color=self.font_color,
)
)
self.heading_level = 0 if tag == "title" else int(tag[1:])
tag_style = self.tag_styles[tag]
hsize = (tag_style.size_pt or self.font_size_pt) / self.pdf.k
align = None
if "align" in attrs_dict:
try:
align_str = attrs_dict.get("align")
align = Align.coerce(align_str) if align_str is not None else None
except ValueError:
align = None
if isinstance(tag_style, TextStyle):
t_margin = tag_style.t_margin
b_margin = tag_style.b_margin
l_margin = tag_style.l_margin
else:
t_margin = b_margin = l_margin = 0.0
l_margin = self._normalize_l_margin(l_margin)
self._new_paragraph(
align=align,
top_margin=t_margin,
bottom_margin=b_margin * hsize,
indent=l_margin,
)
if "color" in css_style:
self.font_color = color_as_decimal(css_style["color"])
elif "color" in attrs_dict:
# "color" attributes are not valid in HTML,
# but we support it for backward compatibility:
self.font_color = color_as_decimal(attrs_dict["color"])
elif tag_style.color is not None and tag_style.color != self.font_color:
self.font_color = tag_style.color
if tag_style.family is not None and tag_style.family != self.font_family:
self.font_family = tag_style.family
if tag_style.size_pt is not None and tag_style.size_pt != self.font_size_pt:
self.font_size_pt = tag_style.size_pt
if tag_style.emphasis:
self.font_emphasis |= tag_style.emphasis
if tag in (
"b",
"blockquote",
"center",
"code",
"del",
"em",
"i",
"dd",
"dt",
"pre",
"s",
"strong",
"u",
):
if tag in BLOCK_TAGS:
self._end_paragraph()
self.style_stack.append(
FontFace(
family=self.font_family,
emphasis=self.font_emphasis,
size_pt=self.font_size_pt,
color=self.font_color,
)
)
tag_style = self.tag_styles[tag]
if tag_style.color:
self.font_color = tag_style.color
self.font_family = tag_style.family or self.font_family
self.font_size_pt = tag_style.size_pt or self.font_size_pt
if tag_style.emphasis:
self.font_emphasis |= tag_style.emphasis
if tag == "pre":
self._pre_formatted = True
self._pre_started = True
if tag in BLOCK_TAGS:
if tag == "dd":
# Not compliant with the HTML spec, but backward-compatible
# cf. https://github.com/py-pdf/fpdf2/pull/1217#discussion_r1666643777
self.follows_heading = True
if isinstance(tag_style, TextStyle):
t_margin = tag_style.t_margin
b_margin = tag_style.b_margin
l_margin = tag_style.l_margin
else:
t_margin = b_margin = l_margin = 0.0
l_margin = self._normalize_l_margin(l_margin)
self._new_paragraph(
line_height=(
self.line_height_stack[-1] if self.line_height_stack else None
),
top_margin=t_margin,
bottom_margin=b_margin,
indent=l_margin,
)
if tag == "ul":
self.indent += 1
bullet_char = attrs_dict.get("type", self.ul_bullet_char)
assert bullet_char is not None
self.bullet.append(bullet_char)
line_height_str = css_style.get(
"line-height", attrs_dict.get("line-height")
)
# "line-height" attributes are not valid in HTML,
# but we support it for backward compatibility,
# because fpdf2 honors it since 2.6.1 and PR #629
if line_height_str:
try:
# YYY parse and convert non-float line_height values
self.line_height_stack.append(float(line_height_str))
except ValueError:
pass
else:
self.line_height_stack.append(None)
if self.indent == 1:
tag_style = self.tag_styles[tag]
if isinstance(tag_style, TextStyle):
t_margin = tag_style.t_margin
b_margin = tag_style.b_margin
l_margin = tag_style.l_margin
else:
t_margin = b_margin = l_margin = 0.0
l_margin = self._normalize_l_margin(l_margin)
self._new_paragraph(
line_height=0,
top_margin=t_margin,
bottom_margin=b_margin,
indent=l_margin,
)
self._write_paragraph("\u00a0")
self._end_paragraph()
if tag == "ol":
self.indent += 1
start = 1
if "start" in attrs_dict:
start_str = attrs_dict["start"] or "1"
try:
start = int(start_str)
except ValueError:
start = 1
self.bullet.append(start - 1)
self.ol_type[self.indent] = attrs_dict.get("type") or "1"
line_height_str = css_style.get("line-height") or attrs_dict.get(
"line-height"
)
# "line-height" attributes are not valid in HTML,
# but we support it for backward compatibility,
# because fpdf2 honors it since 2.6.1 and PR #629
if line_height_str:
try:
# YYY parse and convert non-float line_height values
self.line_height_stack.append(float(line_height_str))
except ValueError:
pass
else:
self.line_height_stack.append(None)
if self.indent == 1:
tag_style = self.tag_styles[tag]
if isinstance(tag_style, TextStyle):
t_margin = tag_style.t_margin
b_margin = tag_style.b_margin
l_margin = tag_style.l_margin
else:
t_margin = b_margin = l_margin = 0.0
l_margin = self._normalize_l_margin(l_margin)
self._new_paragraph(
line_height=0,
top_margin=t_margin,
bottom_margin=b_margin,
indent=l_margin,
)
self._write_paragraph("\u00a0")
self._end_paragraph()
if tag == "li":
prev_text_color = self.pdf.text_color
self.pdf.text_color = self.li_prefix_color
if self.bullet:
bullet = self.bullet[self.indent - 1]
else:
# Allow - to be used outside of
or .
bullet = self.ul_bullet_char
if not isinstance(bullet, int):
bullet = ul_prefix(bullet, self.pdf.is_ttf_font)
if not isinstance(bullet, str):
bullet += 1
self.bullet[self.indent - 1] = bullet
ol_type = self.ol_type[self.indent]
bullet = f"{ol_prefix(ol_type, bullet)}."
tag_style = self.tag_styles[tag]
if isinstance(tag_style, TextStyle):
b_margin = tag_style.b_margin
l_margin = tag_style.l_margin
t_margin = tag_style.t_margin
else:
b_margin = l_margin = t_margin = 0.0
l_margin = self._normalize_l_margin(l_margin)
self._ln(t_margin)
numeric_indent = 0.0 if isinstance(l_margin, Align) else l_margin
self._new_paragraph(
line_height=(
self.line_height_stack[-1] if self.line_height_stack else None
),
indent=numeric_indent * self.indent,
bottom_margin=b_margin,
bullet=bullet,
)
self.pdf.text_color = prev_text_color
if tag == "font":
self.style_stack.append(
FontFace(
family=self.font_family,
emphasis=self.font_emphasis,
size_pt=self.font_size_pt,
color=self.font_color,
)
)
if "color" in attrs_dict:
self.font_color = color_as_decimal(attrs_dict["color"])
if "font-size" in css_style:
font_size_str = css_style.get("font-size") or ""
try:
self.font_size_pt = float(font_size_str)
except ValueError:
pass
elif "size" in attrs_dict:
font_size_str = attrs_dict.get("size") or ""
try:
self.font_size_pt = float(font_size_str)
except ValueError:
pass
if "face" in attrs_dict:
font_family = attrs_dict.get("face") or ""
self.font_family = font_family.lower()
if tag == "table":
self._end_paragraph()
width: Optional[float] = None
width_str = css_style.get("width") or attrs_dict.get("width")
if width_str:
if width_str[-1] == "%":
width = self.pdf.epw * float(width_str[:-1]) / 100
else:
width = float(width_str) / self.pdf.k
if "border" not in attrs_dict: # default borders
borders_layout = (
"HORIZONTAL_LINES"
if self.table_line_separators
else "SINGLE_TOP_LINE"
)
else:
borders_str = attrs_dict["border"] or ""
try:
if int(borders_str) > 0: # explicitly enabled borders
borders_layout = (
"ALL"
if self.table_line_separators
else "NO_HORIZONTAL_LINES"
)
else: # explicitly disabled borders
borders_layout = "NONE"
except ValueError:
borders_layout = "NONE"
align = Align.coerce(attrs_dict.get("align") or "CENTER")
padding = (
float(attrs_dict["cellpadding"] or 0)
if "cellpadding" in attrs_dict
else None
)
spacing = float(attrs_dict.get("cellspacing") or 0)
self.table = Table(
self.pdf,
align=align,
borders_layout=borders_layout,
line_height=self.font_size_pt / self.pdf.k * self.TABLE_LINE_HEIGHT,
width=width,
padding=padding,
gutter_width=spacing,
gutter_height=spacing,
)
self._ln()
if tag == "tr":
if not self.table:
raise FPDFException("Invalid HTML: used outside any ")
self.tr = {k.lower(): v for k, v in attrs_dict.items()}
self.table_row = self.table.row()
if tag in ("td", "th"):
if not self.table or not self.table_row:
raise FPDFException(f"Invalid HTML: <{tag}> used outside any ")
self.td_th = {k.lower(): v for k, v in attrs_dict.items()}
self.td_th["tag"] = tag
if tag == "th":
if "align" not in self.td_th:
self.td_th["align"] = "CENTER"
self.td_th["b"] = True
elif len(self.table.rows) == 1 and not self.table_row.cells:
# => we are in the 1st , and the 1st cell is a |
# => we do not treat the first row as a header
# pylint: disable=protected-access
self.table._first_row_as_headings = (
False # pyright: ignore[reportPrivateUsage]
)
self.table._num_heading_rows = 0 # pyright: ignore[reportPrivateUsage]
if "height" in attrs_dict:
LOGGER.warning(
'Ignoring unsupported height="%s" specified on a <%s>',
attrs_dict["height"],
tag,
)
if "width" in attrs_dict:
width_str = attrs_dict["width"] or "0"
# pylint: disable=protected-access
if len(self.table.rows) == 1: # => first table row
if width_str[-1] == "%":
width = float(width_str[:-1])
else:
width = float(width_str)
if not self.table._col_widths:
self.table._col_widths = []
assert isinstance(self.table._col_widths, list)
self.table._col_widths.append(width)
else:
LOGGER.warning(
'Ignoring width="%s" specified on a <%s> that is not in the first | ',
width_str,
tag,
)
if tag == "img" and "src" in attrs_dict:
width = float(attrs_dict.get("width") or 0) / self.pdf.k
height = float(attrs_dict.get("height") or 0) / self.pdf.k
if self.table_row: # => in a
if width or height:
LOGGER.warning(
'Ignoring unsupported "width" / "height" set on element'
)
if self.align:
LOGGER.warning("Ignoring unsupported alignment")
self.table_row.cell(img=attrs_dict["src"], img_fill_width=True)
assert self.td_th is not None
self.td_th["inserted"] = True
return
x: float | Align = self.pdf.get_x()
if self.align:
x = self.align
self.pdf.image(
self.image_map(attrs_dict["src"] or ""),
x=x,
w=width,
h=height,
link=self.href,
)
if tag == "toc":
self._end_paragraph()
self.pdf.insert_toc_placeholder(
self.render_toc, pages=int(attrs_dict.get("pages") or "1")
)
if tag == "sup":
self.pdf.char_vpos = CharVPos.SUP
if tag == "sub":
self.pdf.char_vpos = CharVPos.SUB
if tag == "title":
self._in_title = True
if css_style.get("break-after") == "page":
if tag in ("br", "hr", "img"):
self._end_paragraph()
# pylint: disable=protected-access
self.pdf._perform_page_break() # pyright: ignore[reportPrivateUsage]
else:
self._page_break_after_paragraph = True
def handle_endtag(self, tag: str) -> None:
while (
self._tags_stack
and tag != self._tags_stack[-1]
and self._tags_stack[-1] in self.HTML_UNCLOSED_TAGS
):
self._tags_stack.pop()
if not self._tags_stack:
if self.warn_on_tags_not_matching:
LOGGER.warning(
"Unexpected HTML end tag %s>, start tag may be missing?", tag
)
elif tag == self._tags_stack[-1]:
self._tags_stack.pop()
elif self.warn_on_tags_not_matching:
LOGGER.warning(
"Unexpected HTML end tag %s>, start tag was <%s>",
tag,
self._tags_stack[-1],
)
if tag == "a":
self.href = ""
if tag == "p":
if self.style_stack:
font_face = self.style_stack.pop()
self.font_family = font_face.family or self.font_family
self.font_size_pt = font_face.size_pt or self.font_size_pt
self.font_emphasis = font_face.emphasis or TextEmphasis.NONE
self.font_color = font_face.color
self._end_paragraph()
self.align = None
if tag in HEADING_TAGS:
self.heading_level = None
if self.style_stack:
font_face = self.style_stack.pop()
self.font_family = font_face.family or self.font_family
self.font_size_pt = font_face.size_pt or self.font_size_pt
self.font_emphasis = font_face.emphasis or TextEmphasis.NONE
self.font_color = font_face.color
self._end_paragraph()
self.follows_heading = True # We don't want extra space below a heading.
if tag in (
"b",
"blockquote",
"center",
"code",
"em",
"i",
"dd",
"dt",
"pre",
"s",
"strong",
"u",
):
if self.style_stack:
font_face = self.style_stack.pop()
self.font_family = font_face.family or self.font_family
self.font_size_pt = font_face.size_pt or self.font_size_pt
self.font_emphasis = font_face.emphasis or TextEmphasis.NONE
self.font_color = font_face.color
if tag == "pre":
self._pre_formatted = False
self._pre_started = False
if tag in BLOCK_TAGS:
self._end_paragraph()
if tag in ("ul", "ol"):
self._end_paragraph()
if tag == "ol":
self.ol_type.pop(self.indent)
self.indent -= 1
self.line_height_stack.pop()
self.bullet.pop()
if tag == "table":
assert self.table is not None
self.table.render()
self.table = None
self._ln()
if tag == "tr":
self.tr = None
self.table_row = None
if tag in ("td", "th"):
assert (
self.td_th is not None
and self.tr is not None
and self.table_row is not None
)
if "inserted" not in self.td_th:
# handle_data() was not called => we call it to produce an empty cell:
bgcolor = color_as_decimal(
self.td_th.get("bgcolor") or self.tr.get("bgcolor", None)
)
style = FontFace(fill_color=bgcolor) if bgcolor else None
colspan = int(self.td_th.get("colspan", "1"))
rowspan = int(self.td_th.get("rowspan", "1"))
self.table_row.cell(
text="", style=style, colspan=colspan, rowspan=rowspan
)
self.td_th = None
if tag == "font":
if self.style_stack:
font_face = self.style_stack.pop()
self.font_family = font_face.family or self.font_family
self.font_size_pt = font_face.size_pt or self.font_size_pt
self.font_emphasis = font_face.emphasis or TextEmphasis.NONE
self.font_color = font_face.color
if tag == "sup":
self.pdf.char_vpos = CharVPos.LINE
if tag == "sub":
self.pdf.char_vpos = CharVPos.LINE
if tag == "title":
self._in_title = False
def feed(self, data: str) -> None:
super().feed(data)
while self._tags_stack and self._tags_stack[-1] in self.HTML_UNCLOSED_TAGS:
self._tags_stack.pop()
self._end_paragraph() # render the final chunk of text and clean up our local context.
if self._tags_stack and self.warn_on_tags_not_matching:
LOGGER.warning("Missing HTML end tag for <%s>", self._tags_stack[-1])
def put_link(self, text: str) -> None:
"Insert a hyperlink"
prev_style = FontFace(
family=self.font_family,
emphasis=self.font_emphasis,
size_pt=self.font_size_pt,
color=self.font_color,
)
tag_style = self.tag_styles["a"]
if tag_style.color:
self.font_color = tag_style.color
self.font_family = tag_style.family or self.font_family
self.font_size_pt = tag_style.size_pt or self.font_size_pt
if tag_style.emphasis:
self.font_emphasis |= tag_style.emphasis
self._write_paragraph(text, link=self.href)
# Restore previous style:
self.font_family = prev_style.family or self.font_family
self.font_size_pt = prev_style.size_pt or self.font_size_pt
self.font_emphasis = prev_style.emphasis or TextEmphasis.NONE
self.font_color = prev_style.color
# pylint: disable=no-self-use
def render_toc(self, pdf: "FPDF", outline: list[OutlineSection]) -> None:
"This method can be overridden by subclasses to customize the Table of Contents style."
pdf.ln()
for section in outline:
link = pdf.add_link(page=section.page_number)
text = f'{" " * section.level * 2} {section.name}'
text += f' {"." * (60 - section.level*2 - len(section.name))} {section.page_number}'
pdf.multi_cell(
w=pdf.epw,
h=pdf.font_size,
text=text,
new_x=XPos.LMARGIN,
new_y=YPos.NEXT,
link=link,
)
# Subclasses of _markupbase.ParserBase must implement this:
def error(self, message: str) -> None:
raise RuntimeError(message)
def _scale_units(
pdf: "FPDF", in_tag_styles: dict[str, FontFace | TextStyle]
) -> dict[str, FontFace | TextStyle]:
conversion_factor = get_scale_factor("mm") / pdf.k
out_tag_styles: dict[str, FontFace | TextStyle] = {}
for tag_name, tag_style in in_tag_styles.items():
if isinstance(tag_style, TextStyle):
out_tag_styles[tag_name] = tag_style.replace(
t_margin=tag_style.t_margin * conversion_factor,
l_margin=(
tag_style.l_margin * conversion_factor
if isinstance(tag_style.l_margin, (int, float))
else tag_style.l_margin
),
b_margin=tag_style.b_margin * conversion_factor,
)
else:
out_tag_styles[tag_name] = tag_style
return out_tag_styles
def ul_prefix(ul_type: str, is_ttf_font: bool) -> str:
if ul_type == "disc":
return BULLET_UNICODE if is_ttf_font else MESSAGE_WAITING_WIN1252
if ul_type == "circle":
return RING_OPERATOR_UNICODE if is_ttf_font else DEGREE_SIGN_WIN1252
if len(ul_type) == 1:
return ul_type
raise NotImplementedError(f"Unsupported type: {ul_type}")
def ol_prefix(ol_type: str, index: int) -> int | str:
if ol_type == "1":
return index
if ol_type == "a":
return ascii_lowercase[index - 1]
if ol_type == "A":
return ascii_uppercase[index - 1]
if ol_type == "I":
return int2roman(index)
if ol_type == "i":
return int2roman(index).lower()
raise NotImplementedError(f"Unsupported type: {ol_type}")
class HTMLMixin:
"""
[**DEPRECATED since v2.6.0**]
You can now directly use the `FPDF.write_html()` method
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
warnings.warn(
(
"The HTMLMixin class is deprecated since v2.6.0. "
"Simply use the FPDF class as a replacement."
),
DeprecationWarning,
stacklevel=get_stack_level(),
)
|