diff --git a/CHANGELOG.md b/CHANGELOG.md index cada1c04e..1b869323d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,15 @@ This can also be enabled programmatically with `warnings.simplefilter('default', ## [2.7.9] - Not released yet ### Added * support for overriding paragraph direction on bidirectional text -* new optional `ul_bullet_color` parameter for `FPDF.write_html()` -### Fixed - +* new optional `li_prefix_color` parameter for `FPDF.write_html()` +* support for `start` & `type` attributes of `
`, `- `... +* [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html) now accepts a `tag_indents` parameter to control, for example, the indent of `
` elements ### Changed * improved the performance of `FPDF.start_section()` - _cf._ [issue #1092](https://github.com/py-pdf/fpdf2/issues/1092) - +### Deprecated +* The `dd_tag_indent` & `li_tag_indent` parameters of `FPDF.write_html()` are replaced by the new `tag_indents` generic parameter. +* The `heading_sizes` & `pre_code_font` parameters of `FPDF.write_html()` are replaced by the new `tag_styles` generic parameter. ## [2.7.8] - 2024-02-09 ### Added diff --git a/docs/HTML.md b/docs/HTML.md index f68a94ab7..810d9e3d0 100644 --- a/docs/HTML.md +++ b/docs/HTML.md @@ -40,8 +40,16 @@ pdf.write_html("""Other section title
--
- unordered
- list
- items
+
- ordered
- list
- items
+
+- unordered
+- list
+- items
++
- ordered
+- list
+- items
+
i am preformatted text.@@ -71,6 +79,45 @@ pdf.output("html.pdf") ``` +## Styling HTML tags globally + +The style of several HTML tags (``, ``, ``, `
`, ``, `
`, `
`...) can be set globally, for the whole HTML document, by passing `tag_styles` to `FPDF.write_html()`: + +```python +from fpdf import FPDF, FontFace + +pdf = FPDF() +pdf.add_page() +pdf.write_html(""" +
Big title
++ +""", tag_styles={ + "h1": FontFace(color=(148, 139, 139), size_pt=32), + "h2": FontFace(color=(148, 139, 139), size_pt=24), +}) +pdf.output("html_styled.pdf") +``` + +Similarly, the indentation of several HTML tags (`Section title
+Hello world!
+`, `- `, `
- `) can be set globally, for the whole HTML document, by passing `tag_indents` to `FPDF.write_html()`: + +```python +from fpdf import FPDF + +pdf = FPDF() +pdf.add_page() +pdf.write_html(""" +
+
+""", tag_indents={"dd": 5}) +pdf.output("html_dd_indented.pdf") +``` + + ## Supported HTML features * `- Term
+- Definition
+` to `
`: headings (and `align` attribute) diff --git a/fpdf/drawing.py b/fpdf/drawing.py index 26d0a8d3d..221dd4d1f 100644 --- a/fpdf/drawing.py +++ b/fpdf/drawing.py @@ -214,7 +214,7 @@ def __new__(cls, r, g, b, a=None): @property def colors(self): - """The color components as a tuple in order `(r, g, b)` with alpha omitted.""" + "The color components as a tuple in order `(r, g, b)` with alpha omitted, in range 0-1." return self[:-1] @property @@ -262,8 +262,8 @@ def __new__(cls, g, a=None): @property def colors(self): - """The color components as a tuple in order (g,) with alpha omitted.""" - return self[:-1] + "The color components as a tuple in order (r, g, b) with alpha omitted, in range 0-1." + return self.g, self.g, self.g @property def colors255(self): @@ -271,7 +271,7 @@ def colors255(self): return tuple(255 * v for v in self.colors) def serialize(self) -> str: - return " ".join(number_to_str(val) for val in self.colors) + f" {self.OPERATOR}" + return f"{number_to_str(self.g)} {self.OPERATOR}" __pdoc__["DeviceGray.OPERATOR"] = False @@ -322,8 +322,7 @@ def __new__(cls, c, m, y, k, a=None): @property def colors(self): - """The color components as a tuple in order (c, m, y, k) with alpha omitted.""" - + "The color components as a tuple in order (c, m, y, k) with alpha omitted, in range 0-1." return self[:-1] def serialize(self) -> str: diff --git a/fpdf/enums.py b/fpdf/enums.py index 0002669c3..1db0abdf1 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -227,6 +227,9 @@ class TextEmphasis(CoerciveIntFlag): style = B | I """ + NONE = 0 + "No emphasis" + B = 1 "Bold" @@ -246,7 +249,7 @@ def style(self): def coerce(cls, value): if isinstance(value, str): if value == "": - return 0 + return cls.NONE if value.upper() == "BOLD": return cls.B if value.upper() == "ITALICS": diff --git a/fpdf/fonts.py b/fpdf/fonts.py index 87379f48e..6d93a796d 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -51,7 +51,9 @@ class FontFace: "fill_color", ) family: Optional[str] - emphasis: Optional[TextEmphasis] # can be a combination: B | U + emphasis: Optional[TextEmphasis] # None means "no override" + # Whereas "" means "no emphasis" + # This can be a combination: B | U size_pt: Optional[int] # Colors are single number grey scales or (red, green, blue) tuples: color: Optional[Union[DeviceGray, DeviceRGB]] @@ -61,7 +63,7 @@ def __init__( self, family=None, emphasis=None, size_pt=None, color=None, fill_color=None ): self.family = family - self.emphasis = TextEmphasis.coerce(emphasis) if emphasis else None + self.emphasis = None if emphasis is None else TextEmphasis.coerce(emphasis) self.size_pt = size_pt self.color = None if color is None else convert_to_device_color(color) self.fill_color = ( diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index c6af49f3a..ec75d94d5 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -144,7 +144,8 @@ class Image: class TitleStyle(FontFace): def __init__( self, - font_family: Optional[str] = None, + font_family: Optional[str] = None, # None means "no override" + # Whereas "" means "no emphasis" font_style: Optional[str] = None, font_size_pt: Optional[int] = None, color: Union[int, tuple] = None, # grey scale or (red, green, blue), @@ -155,7 +156,7 @@ def __init__( ): super().__init__( font_family, - (font_style or "") + ("U" if underline else ""), + ((font_style or "") + "U") if underline else font_style, font_size_pt, color, ) @@ -401,14 +402,17 @@ def write_html(self, text, *args, **kwargs): text (str): HTML content to render image_map (function): an optional one-argument function that map "src" to new image URLs - li_tag_indent (int): numeric indentation of - elements - dd_tag_indent (int): numeric indentation of
- elements + li_tag_indent (int): [**DEPRECATED since v2.7.8**] numeric indentation of
- elements - Set tag_indents instead + dd_tag_indent (int): [**DEPRECATED since v2.7.8**] numeric indentation of
- elements - Set tag_indents instead table_line_separators (bool): enable horizontal line separators in
- ul_bullet_char (str): bullet character for
elements - ul_bullet_color (tuple | str | drawing.Device* instance): color of the
bullets - heading_sizes (dict): font size per heading level names ("h1", "h2"...) - pre_code_font (str): font to use for
&blocks + ul_bullet_char (str): bullet character preceding
- items in
lists. + li_prefix_color (tuple | str | drawing.Device* instance): color for bullets or numbers preceding
- tags. + This applies to both
&
lists. + heading_sizes (dict): [**DEPRECATED since v2.7.8**] font size per heading level names ("h1", "h2"...) - Set tag_styles instead + pre_code_font (str): [**DEPRECATED since v2.7.8**] font to use for
&blocks - Set tag_styles instead warn_on_tags_not_matching (bool): control warnings production for unmatched HTML tags + tag_indents (dict): mapping of HTML tag names to numeric values representing their horizontal left identation + tag_styles (dict): mapping of HTML tag names to colors """ kwargs2 = vars(self) # Method arguments must override class & instance attributes: @@ -894,7 +898,7 @@ def add_page( if isinstance(self.page_background, tuple): self.set_fill_color(*self.page_background) self.rect(0, 0, self.w, self.h, style="F") - self.set_fill_color(*(255 * v for v in fc.colors)) + self.set_fill_color(*fc.colors255) else: self.image(self.page_background, 0, 0, self.w, self.h) @@ -1104,7 +1108,7 @@ def set_page_background(self, background): background, (str, io.BytesIO, Image, DeviceRGB, tuple, type(None)) ): if isinstance(background, DeviceRGB): - self.page_background = tuple(255 * v for v in background.colors) + self.page_background = background.colors255 else: self.page_background = background else: @@ -4936,7 +4940,11 @@ def use_font_face(self, font_face: FontFace): prev_font = (self.font_family, self.font_style, self.font_size_pt) self.set_font( font_face.family or self.font_family, - font_face.emphasis.style if font_face.emphasis is not None else "", + ( + font_face.emphasis.style + if font_face.emphasis is not None + else self.font_style + ), font_face.size_pt or self.font_size_pt, ) prev_text_color = self.text_color diff --git a/fpdf/html.py b/fpdf/html.py index 9f6cb475e..c0685ece4 100644 --- a/fpdf/html.py +++ b/fpdf/html.py @@ -7,6 +7,7 @@ """ from html.parser import HTMLParser +from string import ascii_lowercase, ascii_uppercase import logging, re, warnings from .deprecation import get_stack_level @@ -15,10 +16,29 @@ from .errors import FPDFException from .fonts import FontFace from .table import Table +from .util import int2roman LOGGER = logging.getLogger(__name__) BULLET_WIN1252 = "\x95" # BULLET character in Windows-1252 encoding -DEFAULT_HEADING_SIZES = dict(h1=24, h2=18, h3=14, h4=12, h5=10, h6=8) +DEGREE_WIN1252 = "\xb0" +HEADING_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6") +DEFAULT_TAG_STYLES = { + "a": FontFace(color=(0, 0, 255)), + "blockquote": FontFace(color=(100, 0, 45)), + "code": FontFace(family="Courier"), + "h1": FontFace(color=(150, 0, 0), size_pt=24), + "h2": FontFace(color=(150, 0, 0), size_pt=18), + "h3": FontFace(color=(150, 0, 0), size_pt=14), + "h4": FontFace(color=(150, 0, 0), size_pt=12), + "h5": FontFace(color=(150, 0, 0), size_pt=10), + "h6": FontFace(color=(150, 0, 0), size_pt=8), + "pre": FontFace(family="Courier"), +} +DEFAULT_TAG_INDENTS = { + "blockquote": 0, + "dd": 10, + "li": 5, +} # Pattern to substitute whitespace sequences with a single space character each. # The following are all Unicode characters with White_Space classification plus the newline. @@ -232,10 +252,12 @@ def __init__( dd_tag_indent=10, table_line_separators=False, ul_bullet_char=BULLET_WIN1252, - ul_bullet_color=(190, 0, 0), + li_prefix_color=(190, 0, 0), heading_sizes=None, - pre_code_font="courier", + pre_code_font=DEFAULT_TAG_STYLES["pre"].family, warn_on_tags_not_matching=True, + tag_indents=None, + tag_styles=None, **_, ): """ @@ -243,30 +265,27 @@ def __init__( pdf (FPDF): an instance of `fpdf.FPDF` image_map (function): an optional one-argument function that map "src" to new image URLs - li_tag_indent (int): numeric indentation of
- elements - dd_tag_indent (int): numeric indentation of
- elements + li_tag_indent (int): [**DEPRECATED since v2.7.9**] numeric indentation of
- elements - Set tag_indents instead + dd_tag_indent (int): [**DEPRECATED since v2.7.9**] numeric indentation of
- elements - Set tag_indents instead table_line_separators (bool): enable horizontal line separators in
- ul_bullet_char (str): bullet character for
elements - ul_bullet_color (tuple | str | drawing.Device* instance): color of the
bullets - heading_sizes (dict): font size per heading level names ("h1", "h2"...) - pre_code_font (str): font to use for
&blocks + ul_bullet_char (str): bullet character preceding
- items in
lists. + li_prefix_color (tuple | str | drawing.Device* instance): color for bullets or numbers preceding
- tags. + This applies to both
&
lists. + heading_sizes (dict): [**DEPRECATED since v2.7.9**] font size per heading level names ("h1", "h2"...) - Set tag_styles instead + pre_code_font (str): [**DEPRECATED since v2.7.9**] font to use for
&blocks - Set tag_styles instead warn_on_tags_not_matching (bool): control warnings production for unmatched HTML tags + tag_indents (dict): mapping of HTML tag names to numeric values representing their horizontal left identation + tag_styles (dict): mapping of HTML tag names to colors """ super().__init__() self.pdf = pdf self.image_map = image_map or (lambda src: src) - self.li_tag_indent = li_tag_indent - self.dd_tag_indent = dd_tag_indent self.ul_bullet_char = ul_bullet_char - self.ul_bullet_color = ( - color_as_decimal(ul_bullet_color) - if isinstance(ul_bullet_color, str) - else convert_to_device_color(ul_bullet_color).colors255 + self.li_prefix_color = ( + color_as_decimal(li_prefix_color) + if isinstance(li_prefix_color, str) + else convert_to_device_color(li_prefix_color).colors255 ) - self.heading_sizes = dict(**DEFAULT_HEADING_SIZES) - if heading_sizes: - self.heading_sizes.update(heading_sizes) - self.pre_code_font = pre_code_font self.warn_on_tags_not_matching = warn_on_tags_not_matching # We operate in a local context and will only temporarily switch to the outer one for rendering. @@ -277,10 +296,10 @@ def __init__( # If a font was defined previously, we reinstate that seperately after we're finished here. # In this case the TOC will be rendered with that font and not ours. But adding a TOC tag only # makes sense if the whole document gets converted from HTML, so this should be acceptable. - self.style = dict(b=False, i=False, u=False) + self.emphasis = dict(b=False, i=False, u=False) self.font_size = pdf.font_size_pt self.set_font(pdf.font_family or "times", size=self.font_size, set_default=True) - self._prev_font = (pdf.font_family, self.font_size, self.style) + self._prev_font = (pdf.font_family, self.font_size, self.emphasis) self.pdf._push_local_stack() # xpylint: disable=protected-access self._pre_formatted = False # preserve whitespace while True. @@ -291,10 +310,11 @@ def __init__( self.follows_heading = False # We don't want extra space below a heading. self.href = "" self.align = "" - self.font_stack = [] + self.style_stack = [] # list of FontFace self.indent = 0 + self.ol_type = [] # when inside a
tag, can be "a", "A", "i", "I" or "1" self.bullet = [] - self.font_color = tuple((255 * v for v in pdf.text_color.colors)) + self.font_color = pdf.text_color.colors255 self.heading_level = None self.heading_above = 0.2 # extra space above heading, relative to font size self.heading_below = 0.4 # extra space below heading, relative to font size @@ -307,7 +327,75 @@ def __init__( self.table_row = None # becomes a Row instance when processing
tags self.tr = None # becomes a dict of attributes when processing tags self.td_th = 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 + # "inserted" is a special attribute indicating that a cell has be inserted in self.table_row + + if not tag_indents: + tag_indents = {} + if dd_tag_indent != DEFAULT_TAG_INDENTS["dd"]: + warnings.warn( + ( + "The dd_tag_indent parameter is deprecated since v2.7.9 " + "and will be removed in a future release. " + "Set the `tag_indents` parameter instead." + ), + DeprecationWarning, + stacklevel=get_stack_level(), + ) + tag_indents["dd"] = dd_tag_indent + if li_tag_indent != DEFAULT_TAG_INDENTS["li"]: + warnings.warn( + ( + "The li_tag_indent parameter is deprecated since v2.7.9 " + "and will be removed in a future release. " + "Set the `tag_indents` parameter instead." + ), + DeprecationWarning, + stacklevel=get_stack_level(), + ) + tag_indents["li"] = li_tag_indent + for tag in tag_indents: + if tag not in DEFAULT_TAG_INDENTS: + raise NotImplementedError( + f"Cannot set indent for HTML tag <{tag}> (contributions are welcome to add support for this)" + ) + self.tag_indents = {**DEFAULT_TAG_INDENTS, **tag_indents} + + if not tag_styles: + tag_styles = {} + for tag in tag_styles: + 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)" + ) + self.tag_styles = {**DEFAULT_TAG_STYLES, **tag_styles} + 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(size_pt=size) + if pre_code_font != DEFAULT_TAG_STYLES["pre"].family: + 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(), + ) + self.tag_styles["code"] = self.tag_styles["code"].replace( + family=pre_code_font + ) + self.tag_styles["pre"] = self.tag_styles["pre"].replace( + family=pre_code_font + ) def _new_paragraph( self, align=None, line_height=1.0, top_margin=0, bottom_margin=0 @@ -437,7 +525,7 @@ def handle_starttag(self, tag, attrs): self._write_paragraph("\n") tag = "b" if tag == "dd": - self._write_paragraph("\n" + "\u00a0" * self.dd_tag_indent) + self._write_paragraph("\n" + "\u00a0" * self.tag_indents["dd"]) if tag == "strong": tag = "b" if tag == "em": @@ -470,12 +558,18 @@ def handle_starttag(self, tag, attrs): except ValueError: pass self._new_paragraph(align=align, line_height=line_height) - if tag in self.heading_sizes: + if tag in HEADING_TAGS: prev_font_height = self.font_size / self.pdf.k - self.font_stack.append((self.font_face, self.font_size, self.font_color)) + self.style_stack.append( + FontFace( + family=self.font_family, + size_pt=self.font_size, + color=self.font_color, + ) + ) self.heading_level = int(tag[1:]) - hsize_pt = self.heading_sizes[tag] - hsize = hsize_pt / self.pdf.k + tag_style = self.tag_styles[tag] + hsize = (tag_style.size_pt or self.font_size) / self.pdf.k if attrs: align = attrs.get("align") if not align in ["L", "R", "J", "C"]: @@ -487,37 +581,80 @@ def handle_starttag(self, tag, attrs): top_margin=prev_font_height + self.heading_above * hsize, bottom_margin=self.heading_below * hsize, ) - color = ( - color_as_decimal(attrs["color"]) if "color" in attrs else (150, 0, 0) + color = None + if "color" in attrs: + color = color_as_decimal(attrs["color"]) + elif tag_style.color: + color = tag_style.color.colors255 + if color: + self.set_text_color(*color) + self.set_font( + family=tag_style.family or self.font_family, + size=tag_style.size_pt or self.font_size, ) - self.set_text_color(*color) - self.set_font(size=hsize_pt) if tag == "hr": self.pdf.add_page(same=True) if tag == "code": - self.font_stack.append((self.font_face, self.font_size, self.font_color)) - self.set_font(self.pre_code_font, self.font_size) + self.style_stack.append( + FontFace( + family=self.font_family, + size_pt=self.font_size, + color=self.font_color, + ) + ) + tag_style = self.tag_styles[tag] + if tag_style.color: + self.set_text_color(*tag_style.color.colors255) + self.set_font( + family=tag_style.family or self.font_family, + size=tag_style.size_pt or self.font_size, + ) if tag == "pre": - self.font_stack.append((self.font_face, self.font_size, self.font_color)) - self.set_font(self.pre_code_font, self.font_size) + self.style_stack.append( + FontFace( + family=self.font_family, + size_pt=self.font_size, + color=self.font_color, + ) + ) + tag_style = self.tag_styles[tag] + if tag_style.color: + self.set_text_color(*tag_style.color.colors255) + self.set_font( + family=tag_style.family or self.font_family, + size=tag_style.size_pt or self.font_size, + ) self._pre_formatted = True self._new_paragraph() self._pre_started = True if tag == "blockquote": - self.set_text_color(100, 0, 45) + tag_style = self.tag_styles[tag] + if tag_style.color: + self.set_text_color(*tag_style.color.colors255) + self.set_font( + family=tag_style.family or self.font_family, + size=tag_style.size_pt or self.font_size, + ) self.indent += 1 self._new_paragraph(top_margin=3, bottom_margin=3) + if self.tag_indents["blockquote"]: + self._write_paragraph("\u00a0" * self.tag_indents["blockquote"]) if tag == "ul": self.indent += 1 - self.bullet.append(self.ul_bullet_char) + bullet_char = ( + ul_prefix(attrs["type"]) if "type" in attrs else self.ul_bullet_char + ) + self.bullet.append(bullet_char) self._new_paragraph() if tag == "ol": self.indent += 1 - self.bullet.append(0) + start = int(attrs["start"]) if "start" in attrs else 1 + self.bullet.append(start - 1) + self.ol_type.append(attrs.get("type", "1")) self._new_paragraph() if tag == "li": self._ln(2) - self.set_text_color(*self.ul_bullet_color) + self.set_text_color(*self.li_prefix_color) if self.bullet: bullet = self.bullet[self.indent - 1] else: @@ -526,13 +663,20 @@ def handle_starttag(self, tag, attrs): if not isinstance(bullet, str): bullet += 1 self.bullet[self.indent - 1] = bullet - bullet = f"{bullet}. " - indent = "\u00a0" * self.li_tag_indent * self.indent + ol_type = self.ol_type[self.indent - 1] + bullet = f"{ol_prefix(ol_type, bullet)}. " + indent = "\u00a0" * self.tag_indents["li"] * self.indent self._write_paragraph(f"{indent}{bullet} ") self.set_text_color(*self.font_color) if tag == "font": # save previous font state: - self.font_stack.append((self.font_face, self.font_size, self.font_color)) + self.style_stack.append( + FontFace( + family=self.font_family, + size_pt=self.font_size, + color=self.font_color, + ) + ) if "color" in attrs: color = color_as_decimal(attrs["color"]) self.font_color = color @@ -540,7 +684,7 @@ def handle_starttag(self, tag, attrs): face = attrs.get("face").lower() # This may result in a FPDFException "font not found". self.set_font(face) - self.font_face = face + self.font_family = face if "size" in attrs: self.font_size = int(attrs.get("size")) self.set_font() @@ -682,22 +826,22 @@ def handle_endtag(self, tag): tag, self._tags_stack[-1], ) - if tag in self.heading_sizes: + if tag in HEADING_TAGS: self.heading_level = None - face, size, color = self.font_stack.pop() - self.set_font(face, size) - self.set_text_color(*color) + font_face = self.style_stack.pop() + self.set_font(font_face.family, font_face.size_pt) + self.set_text_color(*font_face.color.colors255) self._end_paragraph() self.follows_heading = True # We don't want extra space below a heading. if tag == "code": - face, size, color = self.font_stack.pop() - self.set_font(face, size) - self.set_text_color(*color) + font_face = self.style_stack.pop() + self.set_font(font_face.family, font_face.size_pt) + self.set_text_color(*font_face.color.colors255) if tag == "pre": self._end_paragraph() - face, size, color = self.font_stack.pop() - self.set_font(face, size) - self.set_text_color(*color) + font_face = self.style_stack.pop() + self.set_font(font_face.family, font_face.size_pt) + self.set_text_color(*font_face.color.colors255) self._pre_formatted = False self._pre_started = False if tag == "blockquote": @@ -719,6 +863,8 @@ def handle_endtag(self, tag): if tag in ("ul", "ol"): self._end_paragraph() self.indent -= 1 + if tag == "ol": + self.ol_type.pop() self.bullet.pop() if tag == "table": self.table.render() @@ -738,10 +884,10 @@ def handle_endtag(self, tag): self.td_th = None if tag == "font": # recover last font state - face, size, color = self.font_stack.pop() - self.font_color = color - self.set_font(face, size) - self.set_text_color(*self.font_color) + font_face = self.style_stack.pop() + self.font_color = font_face.color.colors255 + self.set_font(font_face.family, font_face.size_pt) + self.set_text_color(*font_face.color.colors255) if tag == "center": self._end_paragraph() if tag == "sup": @@ -756,24 +902,24 @@ def feed(self, data): self._end_paragraph() # render the final chunk of text and clean up our local context. self.pdf._pop_local_stack() # pylint: disable=protected-access if self._prev_font[0]: # restore previously defined font settings - self.style = self._prev_font[2] + self.emphasis = self._prev_font[2] self.set_font(self._prev_font[0], size=self._prev_font[1], set_default=True) if self._tags_stack and self.warn_on_tags_not_matching: LOGGER.warning("Missing HTML end tag for <%s>", self._tags_stack[-1]) - def set_font(self, face=None, size=None, set_default=False): - if face: - self.font_face = face + def set_font(self, family=None, size=None, set_default=False): + if family: + self.font_family = family if size: self.font_size = size self.h = size / self.pdf.k - style = "".join(s for s in ("b", "i", "u") if self.style.get(s)).upper() - LOGGER.debug(f"set_font: %s style=%s h={self.h:.2f}", self.font_face, style) + style = "".join(s for s in ("b", "i", "u") if self.emphasis.get(s)).upper() + LOGGER.debug(f"set_font: %s style=%s h={self.h:.2f}", self.font_family, style) prev_page = self.pdf.page if not set_default: # make sure there's at least one font defined in the PDF. self.pdf.page = 0 - if (self.font_face, style) != (self.pdf.font_family, self.pdf.font_style): - self.pdf.set_font(self.font_face, style, self.font_size) + if (self.font_family, style) != (self.pdf.font_family, self.pdf.font_style): + self.pdf.set_font(self.font_family, style, self.font_size) if self.font_size != self.pdf.font_size: self.pdf.set_font_size(self.font_size) self.pdf.page = prev_page @@ -781,8 +927,8 @@ def set_font(self, face=None, size=None, set_default=False): def set_style(self, tag=None, enable=False): # Modify style and select corresponding font if tag: - self.style[tag.lower()] = enable - style = "".join(s for s in ("b", "i", "u") if self.style.get(s)) + self.emphasis[tag.lower()] = enable + style = "".join(s for s in ("b", "i", "u") if self.emphasis.get(s)) LOGGER.debug("SET_FONT_STYLE %s", style) prev_page = self.pdf.page self.pdf.page = 0 @@ -797,7 +943,13 @@ def set_text_color(self, r=None, g=0, b=0): def put_link(self, text): # Put a hyperlink - self.set_text_color(0, 0, 255) + tag_style = self.tag_styles["a"] + if tag_style.color: + self.set_text_color(*tag_style.color.colors255) + self.set_font( + family=tag_style.family or self.font_family, + size=tag_style.size_pt or self.font_size, + ) self.set_style("u", True) self._write_paragraph(text, link=self.href) self.set_style("u", False) @@ -825,6 +977,30 @@ def error(self, message): raise RuntimeError(message) +def ul_prefix(ul_type): + if ul_type == "circle": + return DEGREE_WIN1252 + if ul_type == "disc": + return BULLET_WIN1252 + if len(ul_type) == 1: + return ul_type + raise NotImplementedError(f"Unsupported type: {ul_type}") + + +def ol_prefix(ol_type, index): + 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**] diff --git a/fpdf/util.py b/fpdf/util.py index 9734cbd4c..5bfc109d7 100644 --- a/fpdf/util.py +++ b/fpdf/util.py @@ -111,6 +111,33 @@ def convert_unit( return to_convert / unit_conversion_factor +ROMAN_NUMERAL_MAP = ( + ("M", 1000), + ("CM", 900), + ("D", 500), + ("CD", 400), + ("C", 100), + ("XC", 90), + ("L", 50), + ("XL", 40), + ("X", 10), + ("IX", 9), + ("V", 5), + ("IV", 4), + ("I", 1), +) + + +def int2roman(n): + "Convert an integer to Roman numeral" + result = "" + for numeral, integer in ROMAN_NUMERAL_MAP: + while n >= integer: + result += numeral + n -= integer + return result + + ################################################################################ ################### Utility functions to track memory usage #################### ################################################################################ diff --git a/test/drawing/test_drawing.py b/test/drawing/test_drawing.py index 57d92ba6e..a3155f9a6 100644 --- a/test/drawing/test_drawing.py +++ b/test/drawing/test_drawing.py @@ -148,8 +148,8 @@ def test_device_gray(self): gray = fpdf.drawing.DeviceGray(g=0.5) gray_a = fpdf.drawing.DeviceGray(g=0.5, a=0.75) - assert gray.colors == (0.5,) - assert gray_a.colors == (0.5,) + assert gray.colors == (0.5, 0.5, 0.5) + assert gray_a.colors == (0.5, 0.5, 0.5) with pytest.raises(ValueError): fpdf.drawing.DeviceGray(g=2) diff --git a/test/html/html_blockquote_color.pdf b/test/html/html_blockquote_color.pdf new file mode 100644 index 000000000..a2b22f554 Binary files /dev/null and b/test/html/html_blockquote_color.pdf differ diff --git a/test/html/html_blockquote_indent.pdf b/test/html/html_blockquote_indent.pdf new file mode 100644 index 000000000..91089b579 Binary files /dev/null and b/test/html/html_blockquote_indent.pdf differ diff --git a/test/html/html_headings_color.pdf b/test/html/html_headings_color.pdf new file mode 100644 index 000000000..683eb64e4 Binary files /dev/null and b/test/html/html_headings_color.pdf differ diff --git a/test/html/html_ul_bullet_color.pdf b/test/html/html_li_prefix_color.pdf similarity index 100% rename from test/html/html_ul_bullet_color.pdf rename to test/html/html_li_prefix_color.pdf diff --git a/test/html/html_li_tag_indent.pdf b/test/html/html_li_tag_indent.pdf new file mode 100644 index 000000000..a5f732d08 Binary files /dev/null and b/test/html/html_li_tag_indent.pdf differ diff --git a/test/html/html_link_color.pdf b/test/html/html_link_color.pdf new file mode 100644 index 000000000..751b65e0c Binary files /dev/null and b/test/html/html_link_color.pdf differ diff --git a/test/html/html_ol_start_and_type.pdf b/test/html/html_ol_start_and_type.pdf new file mode 100644 index 000000000..bf956f440 Binary files /dev/null and b/test/html/html_ol_start_and_type.pdf differ diff --git a/test/html/html_ul_type.pdf b/test/html/html_ul_type.pdf new file mode 100644 index 000000000..8343eadab Binary files /dev/null and b/test/html/html_ul_type.pdf differ diff --git a/test/html/test_html.py b/test/html/test_html.py index 7d8a0355c..d404ffd0d 100644 --- a/test/html/test_html.py +++ b/test/html/test_html.py @@ -2,8 +2,9 @@ import pytest -from fpdf import FPDF, HTMLMixin, TitleStyle +from fpdf import FPDF, FontFace, HTMLMixin, TitleStyle from fpdf.drawing import DeviceRGB +from fpdf.html import color_as_decimal from fpdf.errors import FPDFException from test.conftest import assert_pdf_equal, LOREM_IPSUM @@ -190,9 +191,9 @@ def test_html_bold_italic_underline(tmp_path): def test_html_customize_ul(tmp_path): html = """ -
""" +- term1: definition1
-- term2: definition2
-- term1: definition1
+- term2: definition2
+ """ # 1. Customizing through class attributes: class CustomPDF(FPDF): @@ -202,21 +203,64 @@ class CustomPDF(FPDF): pdf = CustomPDF() pdf.set_font_size(30) pdf.add_page() - pdf.write_html(html) - pdf.ln() - # 2. Customizing through instance attributes: - pdf.li_tag_indent = 10 - pdf.ul_bullet_char = "\x9b" - pdf.write_html(html) - pdf.ln() - # 3. Customizing through optional method arguments: - for indent, bullet in ((15, "\xac"), (20, "\xb7")): - pdf.write_html(html, li_tag_indent=indent, ul_bullet_char=bullet) + with pytest.warns(DeprecationWarning): # li_tag_indent + pdf.write_html(html) + pdf.ln() + # 2. Customizing through instance attributes: + pdf.li_tag_indent = 10 + pdf.ul_bullet_char = "\x9b" + pdf.write_html(html) pdf.ln() + # 3. Customizing through optional method arguments: + for indent, bullet in ((15, "\xac"), (20, "\xb7")): + pdf.write_html(html, li_tag_indent=indent, ul_bullet_char=bullet) + pdf.ln() assert_pdf_equal(pdf, HERE / "html_customize_ul.pdf", tmp_path) -def test_html_ul_bullet_color(tmp_path): +def test_html_ol_start_and_type(tmp_path): + pdf = FPDF() + pdf.set_font_size(30) + pdf.add_page() + pdf.write_html( + """+
""" + ) + assert_pdf_equal(pdf, HERE / "html_ol_start_and_type.pdf", tmp_path) + + +def test_html_ul_type(tmp_path): + pdf = FPDF() + pdf.set_font_size(30) + pdf.add_page() + pdf.write_html( + text=""" +- item
+- item
+- item
++
+- a list item
++
+ """ + ) + pdf.ln() + pdf.add_font(fname=HERE / "../fonts/DejaVuSans.ttf") + pdf.set_font("DejaVuSans") + pdf.write_html( + """ +- another list item
++
+ """ + ) + assert_pdf_equal(pdf, HERE / "html_ul_type.pdf", tmp_path) + + +def test_html_li_prefix_color(tmp_path): html = """- a list item
+- another list item
+
- item1
- item2
@@ -226,13 +270,13 @@ def test_html_ul_bullet_color(tmp_path): pdf = FPDF() pdf.set_font_size(30) pdf.add_page() - pdf.write_html(html, ul_bullet_color=0) # black + pdf.write_html(html, li_prefix_color=0) # black pdf.ln() - pdf.write_html(html, ul_bullet_color="green") + pdf.write_html(html, li_prefix_color="green") pdf.ln() - pdf.write_html(html, ul_bullet_color=DeviceRGB(r=0.5, g=1, b=0)) + pdf.write_html(html, li_prefix_color=DeviceRGB(r=0.5, g=1, b=0)) pdf.ln() - assert_pdf_equal(pdf, HERE / "html_ul_bullet_color.pdf", tmp_path) + assert_pdf_equal(pdf, HERE / "html_li_prefix_color.pdf", tmp_path) def test_html_align_paragraph(tmp_path): @@ -318,15 +362,16 @@ def test_html_headings_line_height(tmp_path): # issue-223 def test_html_custom_heading_sizes(tmp_path): # issue-223 pdf = FPDF() pdf.add_page() - pdf.write_html( - """This is a H1
-This is a H2
-This is a H3
-This is a H4
-This is a H5
-This is a H6
""", - heading_sizes=dict(h1=6, h2=12, h3=18, h4=24, h5=30, h6=36), - ) + with pytest.warns(DeprecationWarning): + pdf.write_html( + """This is a H1
+This is a H2
+This is a H3
+This is a H4
+This is a H5
+This is a H6
""", + heading_sizes=dict(h1=6, h2=12, h3=18, h4=24, h5=30, h6=36), + ) assert_pdf_equal(pdf, HERE / "html_custom_heading_sizes.pdf", tmp_path) @@ -482,7 +527,8 @@ def test_html_custom_pre_code_font(tmp_path): # issue 770 pdf = FPDF() pdf.add_font(fname=HERE / "../fonts/DejaVuSansMono.ttf") pdf.add_page() - pdf.write_html("Cześć!
", pre_code_font="DejaVuSansMono") + with pytest.warns(DeprecationWarning): + pdf.write_html("Cześć!
", pre_code_font="DejaVuSansMono") assert_pdf_equal(pdf, HERE / "html_custom_pre_code_font.pdf", tmp_path) @@ -561,3 +607,56 @@ def test_html_and_section_title_styles(): # issue 1080This will not overflow
""" ) + + +def test_html_link_color(tmp_path): + pdf = FPDF() + pdf.add_page() + html = 'foo' + pdf.write_html(html, tag_styles={"a": FontFace(color=color_as_decimal("red"))}) + assert_pdf_equal(pdf, HERE / "html_link_color.pdf", tmp_path) + + +def test_html_blockquote_color(tmp_path): + pdf = FPDF() + pdf.add_page() + html = "Text beforefooText afterwards" + pdf.write_html(html, tag_styles={"blockquote": FontFace(color=(125, 125, 0))}) + assert_pdf_equal(pdf, HERE / "html_blockquote_color.pdf", tmp_path) + + +def test_html_headings_color(tmp_path): + pdf = FPDF() + pdf.add_page() + html = "foo
bar
" + pdf.write_html( + html, + tag_styles={ + "h1": FontFace(color=(148, 139, 139), size_pt=24), + "h2": FontFace(color=(148, 139, 139), size_pt=18), + }, + ) + assert_pdf_equal(pdf, HERE / "html_headings_color.pdf", tmp_path) + + +def test_html_unsupported_tag_color(): + pdf = FPDF() + pdf.add_page() + with pytest.raises(NotImplementedError): + pdf.write_html("foo
", tag_styles={"p": FontFace()}) + + +def test_html_blockquote_indent(tmp_path): # issue-1074 + pdf = FPDF() + pdf.add_page() + html = "Text beforefooText afterwards" + pdf.write_html(html, tag_indents={"blockquote": 5}) + assert_pdf_equal(pdf, HERE / "html_blockquote_indent.pdf", tmp_path) + + +def test_html_li_tag_indent(tmp_path): + pdf = FPDF() + pdf.add_page() + with pytest.warns(DeprecationWarning): + pdf.write_html("", li_tag_indent=10) + assert_pdf_equal(pdf, HERE / "html_li_tag_indent.pdf", tmp_path) diff --git a/test/outline/test_outline.py b/test/outline/test_outline.py index 837c9e8ff..aa07bb535 100644 --- a/test/outline/test_outline.py +++ b/test/outline/test_outline.py @@ -173,9 +173,20 @@ def test_toc_with_font_style_override_bold(tmp_path): # issue-1072 pdf = FPDF() pdf.add_page() pdf.set_font("Helvetica", "B") - pdf.set_section_title_styles(TitleStyle("Helvetica", "", 20, (0, 0, 0))) + pdf.set_section_title_styles( + TitleStyle("Helvetica", font_size_pt=20, color=(0, 0, 0)) + ) + pdf.start_section("foo") + assert_pdf_equal(pdf, HERE / "toc_with_font_style_override_bold1.pdf", tmp_path) + + pdf = FPDF() + pdf.add_page() + pdf.set_font("Helvetica", "B") + pdf.set_section_title_styles( + TitleStyle("Helvetica", font_style="", font_size_pt=20, color=(0, 0, 0)) + ) pdf.start_section("foo") - assert_pdf_equal(pdf, HERE / "toc_with_font_style_override_bold.pdf", tmp_path) + assert_pdf_equal(pdf, HERE / "toc_with_font_style_override_bold2.pdf", tmp_path) def test_toc_with_table(tmp_path): # issue-1079 diff --git a/test/outline/toc_with_font_style_override_bold1.pdf b/test/outline/toc_with_font_style_override_bold1.pdf new file mode 100644 index 000000000..e3fab61ef Binary files /dev/null and b/test/outline/toc_with_font_style_override_bold1.pdf differ diff --git a/test/outline/toc_with_font_style_override_bold.pdf b/test/outline/toc_with_font_style_override_bold2.pdf similarity index 100% rename from test/outline/toc_with_font_style_override_bold.pdf rename to test/outline/toc_with_font_style_override_bold2.pdf
- item