From f20c411f076d8ee8f60508826ba6e3539911f118 Mon Sep 17 00:00:00 2001 From: Lucas Cimon <925560+Lucas-C@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:52:21 +0100 Subject: [PATCH 1/3] Supporting tags from SVG files --- fpdf/svg.py | 41 ++++++++++++++++++ test/svg/generated_pdf/text-samples.pdf | Bin 0 -> 1241 bytes test/svg/parameters.py | 1 + .../svg_sources/embedded-raster-images.svg | 2 +- test/svg/svg_sources/text-samples.svg | 13 ++++++ 5 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 test/svg/generated_pdf/text-samples.pdf create mode 100644 test/svg/svg_sources/text-samples.svg diff --git a/fpdf/svg.py b/fpdf/svg.py index fc9d0f7da..f4c1e3818 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -852,6 +852,8 @@ def handle_defs(self, defs): self.build_path(child) elif child.tag in xmlns_lookup("svg", "image"): self.build_image(child) + elif child.tag in xmlns_lookup("svg", "text"): + self.build_text(child) elif child.tag in shape_tags: self.build_shape(child) elif child.tag in xmlns_lookup("svg", "clipPath"): @@ -926,6 +928,8 @@ def build_group(self, group, pdf_group=None): pdf_group.add_item(self.build_xref(child), False) elif child.tag in xmlns_lookup("svg", "image"): pdf_group.add_item(self.build_image(child), False) + elif child.tag in xmlns_lookup("svg", "text"): + pdf_group.add_item(self.build_text(child), False) else: LOGGER.warning( "Ignoring unsupported SVG tag: <%s> (contributions are welcome to add support for it)", @@ -984,6 +988,43 @@ def apply_clipping_path(self, stylable, svg_element): clipping_path_id = re.search(r"url\((\#\w+)\)", clipping_path) stylable.clipping_path = self.cross_references[clipping_path_id[1]] + @force_nodocument + def build_text(self, text): + if "dx" in text.attrib or "dy" in text.attrib: + raise NotImplementedError( + '"dx" / "dy" defined on is currently not supported (but contributions are welcome!)' + ) + if "lengthAdjust" in text.attrib: + raise NotImplementedError( + '"lengthAdjust" defined on is currently not supported (but contributions are welcome!)' + ) + if "rotate" in text.attrib: + raise NotImplementedError( + '"rotate" defined on is currently not supported (but contributions are welcome!)' + ) + if "style" in text.attrib: + raise NotImplementedError( + '"style" defined on is currently not supported (but contributions are welcome!)' + ) + if "textLength" in text.attrib: + raise NotImplementedError( + '"textLength" defined on is currently not supported (but contributions are welcome!)' + ) + if "transform" in text.attrib: + raise NotImplementedError( + '"transform" defined on is currently not supported (but contributions are welcome!)' + ) + font_family = text.attrib.get("font-family") + font_size = text.attrib.get("font-size") + # TODO: reuse code from line_break & text_region modules. + # We could either: + # 1. handle text regions in this module (svg), with a dedicated SVGText class. + # 2. handle text regions in the drawing module, maybe by defining a PaintedPath.text() method. + # This may be the best approach, as we would benefit from the global transformation performed in SVGObject.transform_to_rect_viewport() + svg_text = None + self.update_xref(text.attrib.get("id"), svg_text) + return svg_text + @force_nodocument def build_image(self, image): href = None diff --git a/test/svg/generated_pdf/text-samples.pdf b/test/svg/generated_pdf/text-samples.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8124b0b1f2689e7e649bae89e632024875c74d59 GIT binary patch literal 1241 zcmah}O=uHA6b7jx+k-`_! z1wmT$AmY`7e`pUDi{i!7n+HXOS`?+AAUzaOP{f0H(Kq>NmK5wOSlkqCp$EKTQgrA-vE8O@NqW!7>cR?U-sWR?sA641~=ebOos#3oEETSlp1 zR?QBg@RIc+vQBiv8h4eU5F6-Cf=%Kee>Emw&){Ud4;lbGZ~`<`vm~)&mFx_5P{<91f_CAcCF?lUyg{%HJsLW}3eH$r%&>#l z94Ds`FLE@>AvV?vO!-Foa|J+|bw*LS>>v`}q`FUlvY~NN}+lRikUHzdwVztEb z_1!zC!@aA+`(7^3f86S2jP6~nxo0b5T4w01r+xm6G57w($SAqD*0mJszg=Mrv@cO2ndnQJt}%QM7K$W&`?NXyxv$sW&Qy~w_}a~ literal 0 HcmV?d00001 diff --git a/test/svg/parameters.py b/test/svg/parameters.py index 22a744fb2..e4f5533b9 100644 --- a/test/svg/parameters.py +++ b/test/svg/parameters.py @@ -783,6 +783,7 @@ def Gs(**kwargs): svgfile("path_clippingpath.svg"), id=" containing a used in a group with color - issue #1147", ), + pytest.param(svgfile("text-samples.svg"), id=" tests"), ) svg_path_edge_cases = ( diff --git a/test/svg/svg_sources/embedded-raster-images.svg b/test/svg/svg_sources/embedded-raster-images.svg index ffef5b6ed..ddb936169 100644 --- a/test/svg/svg_sources/embedded-raster-images.svg +++ b/test/svg/svg_sources/embedded-raster-images.svg @@ -1,7 +1,7 @@ - Example image.svg - embedding raster images + Example embedded-raster-images.svg diff --git a/test/svg/svg_sources/text-samples.svg b/test/svg/svg_sources/text-samples.svg new file mode 100644 index 000000000..281f15112 --- /dev/null +++ b/test/svg/svg_sources/text-samples.svg @@ -0,0 +1,13 @@ + + + Example text-samples.svg + + + + My + cat + is + Grumpy! + + From 61f37656866998648815d99e8e9ea21781ba03b4 Mon Sep 17 00:00:00 2001 From: Lucas Cimon <925560+Lucas-C@users.noreply.github.com> Date: Wed, 22 Jan 2025 01:53:34 +0100 Subject: [PATCH 2/3] WIP --- fpdf/drawing.py | 54 ++++++++++----------- fpdf/fonts.py | 4 +- fpdf/fpdf.py | 3 +- fpdf/svg.py | 62 ++++++++++++++++++------ test/svg/generated_pdf/text-samples.pdf | Bin 1241 -> 1247 bytes test/svg/svg_sources/text-samples.svg | 8 ++- 6 files changed, 84 insertions(+), 47 deletions(-) diff --git a/fpdf/drawing.py b/fpdf/drawing.py index 29ee6cd06..81cfc0e00 100644 --- a/fpdf/drawing.py +++ b/fpdf/drawing.py @@ -3104,13 +3104,13 @@ class DrawingContext: def __init__(self): self._subitems = [] - def add_item(self, item, _copy=True): + def add_item(self, item, clone=True): """ Append an item to this drawing context Args: item (GraphicsContext, PaintedPath): the item to be appended. - _copy (bool): if true (the default), the item will be copied before being + clone (bool): if true (the default), the item will be copied before being appended. This prevents modifications to a referenced object from "retroactively" altering its style/shape and should be disabled with caution. @@ -3119,7 +3119,7 @@ def add_item(self, item, _copy=True): if not isinstance(item, (GraphicsContext, PaintedPath)): raise TypeError(f"{item} doesn't belong in a DrawingContext") - if _copy: + if clone: item = copy.deepcopy(item) self._subitems.append(item) @@ -3358,24 +3358,24 @@ def transform_group(self, transform): ctxt.transform = transform yield self - def add_path_element(self, item, _copy=True): + def add_path_element(self, item, clone=True): """ Add the given element as a path item of this path. Args: item: the item to add to this path. - _copy (bool): if true (the default), the item will be copied before being + clone (bool): if true (the default), the item will be copied before being appended. This prevents modifications to a referenced object from "retroactively" altering its style/shape and should be disabled with caution. """ if self._starter_move is not None: self._closed = False - self._graphics_context.add_item(self._starter_move, _copy=False) + self._graphics_context.add_item(self._starter_move, clone=False) self._close_context = self._graphics_context self._starter_move = None - self._graphics_context.add_item(item, _copy=_copy) + self._graphics_context.add_item(item, clone=clone) def remove_last_path_element(self): self._graphics_context.remove_last_item() @@ -3405,7 +3405,7 @@ def rectangle(self, x, y, w, h, rx=0, ry=0): self._insert_implicit_close_if_open() self.add_path_element( - RoundedRectangle(Point(x, y), Point(w, h), Point(rx, ry)), _copy=False + RoundedRectangle(Point(x, y), Point(w, h), Point(rx, ry)), clone=False ) self._closed = True self.move_to(x, y) @@ -3440,7 +3440,7 @@ def ellipse(self, cx, cy, rx, ry): The path, to allow chaining method calls. """ self._insert_implicit_close_if_open() - self.add_path_element(Ellipse(Point(rx, ry), Point(cx, cy)), _copy=False) + self.add_path_element(Ellipse(Point(rx, ry), Point(cx, cy)), clone=False) self._closed = True self.move_to(cx, cy) @@ -3484,7 +3484,7 @@ def move_relative(self, x, y): self._insert_implicit_close_if_open() if self._starter_move is not None: self._closed = False - self._graphics_context.add_item(self._starter_move, _copy=False) + self._graphics_context.add_item(self._starter_move, clone=False) self._close_context = self._graphics_context self._starter_move = RelativeMove(Point(x, y)) return self @@ -3500,7 +3500,7 @@ def line_to(self, x, y): Returns: The path, to allow chaining method calls. """ - self.add_path_element(Line(Point(x, y)), _copy=False) + self.add_path_element(Line(Point(x, y)), clone=False) return self def line_relative(self, dx, dy): @@ -3517,7 +3517,7 @@ def line_relative(self, dx, dy): Returns: The path, to allow chaining method calls. """ - self.add_path_element(RelativeLine(Point(dx, dy)), _copy=False) + self.add_path_element(RelativeLine(Point(dx, dy)), clone=False) return self def horizontal_line_to(self, x): @@ -3531,7 +3531,7 @@ def horizontal_line_to(self, x): Returns: The path, to allow chaining method calls. """ - self.add_path_element(HorizontalLine(x), _copy=False) + self.add_path_element(HorizontalLine(x), clone=False) return self def horizontal_line_relative(self, dx): @@ -3547,7 +3547,7 @@ def horizontal_line_relative(self, dx): Returns: The path, to allow chaining method calls. """ - self.add_path_element(RelativeHorizontalLine(dx), _copy=False) + self.add_path_element(RelativeHorizontalLine(dx), clone=False) return self def vertical_line_to(self, y): @@ -3561,7 +3561,7 @@ def vertical_line_to(self, y): Returns: The path, to allow chaining method calls. """ - self.add_path_element(VerticalLine(y), _copy=False) + self.add_path_element(VerticalLine(y), clone=False) return self def vertical_line_relative(self, dy): @@ -3577,7 +3577,7 @@ def vertical_line_relative(self, dy): Returns: The path, to allow chaining method calls. """ - self.add_path_element(RelativeVerticalLine(dy), _copy=False) + self.add_path_element(RelativeVerticalLine(dy), clone=False) return self def curve_to(self, x1, y1, x2, y2, x3, y3): @@ -3599,7 +3599,7 @@ def curve_to(self, x1, y1, x2, y2, x3, y3): ctrl2 = Point(x2, y2) end = Point(x3, y3) - self.add_path_element(BezierCurve(ctrl1, ctrl2, end), _copy=False) + self.add_path_element(BezierCurve(ctrl1, ctrl2, end), clone=False) return self def curve_relative(self, dx1, dy1, dx2, dy2, dx3, dy3): @@ -3633,7 +3633,7 @@ def curve_relative(self, dx1, dy1, dx2, dy2, dx3, dy3): c2d = Point(dx2, dy2) end = Point(dx3, dy3) - self.add_path_element(RelativeBezierCurve(c1d, c2d, end), _copy=False) + self.add_path_element(RelativeBezierCurve(c1d, c2d, end), clone=False) return self def quadratic_curve_to(self, x1, y1, x2, y2): @@ -3651,7 +3651,7 @@ def quadratic_curve_to(self, x1, y1, x2, y2): """ ctrl = Point(x1, y1) end = Point(x2, y2) - self.add_path_element(QuadraticBezierCurve(ctrl, end), _copy=False) + self.add_path_element(QuadraticBezierCurve(ctrl, end), clone=False) return self def quadratic_curve_relative(self, dx1, dy1, dx2, dy2): @@ -3673,7 +3673,7 @@ def quadratic_curve_relative(self, dx1, dy1, dx2, dy2): """ ctrl = Point(dx1, dy1) end = Point(dx2, dy2) - self.add_path_element(RelativeQuadraticBezierCurve(ctrl, end), _copy=False) + self.add_path_element(RelativeQuadraticBezierCurve(ctrl, end), clone=False) return self def arc_to(self, rx, ry, rotation, large_arc, positive_sweep, x, y): @@ -3720,7 +3720,7 @@ def arc_to(self, rx, ry, rotation, large_arc, positive_sweep, x, y): end = Point(x, y) self.add_path_element( - Arc(radii, rotation, large_arc, positive_sweep, end), _copy=False + Arc(radii, rotation, large_arc, positive_sweep, end), clone=False ) return self @@ -3769,7 +3769,7 @@ def arc_relative(self, rx, ry, rotation, large_arc, positive_sweep, dx, dy): end = Point(dx, dy) self.add_path_element( - RelativeArc(radii, rotation, large_arc, positive_sweep, end), _copy=False + RelativeArc(radii, rotation, large_arc, positive_sweep, end), clone=False ) return self @@ -3777,13 +3777,13 @@ def close(self): """ Explicitly close the current (sub)path. """ - self.add_path_element(Close(), _copy=False) + self.add_path_element(Close(), clone=False) self._closed = True self.move_relative(0, 0) def _insert_implicit_close_if_open(self): if not self._closed: - self._close_context.add_item(ImplicitClose(), _copy=False) + self._close_context.add_item(ImplicitClose(), clone=False) self._close_context = self._graphics_context self._closed = True @@ -3970,19 +3970,19 @@ def clipping_path(self): def clipping_path(self, new_clipath): self._clipping_path = new_clipath - def add_item(self, item, _copy=True): + def add_item(self, item, clone=True): """ Add a path element to this graphics context. Args: item: the path element to add. May be a primitive element or another `GraphicsContext` or a `PaintedPath`. - _copy (bool): if true (the default), the item will be copied before being + clone (bool): if true (the default), the item will be copied before being appended. This prevents modifications to a referenced object from "retroactively" altering its style/shape and should be disabled with caution. """ - if _copy: + if clone: item = copy.deepcopy(item) self.path_items.append(item) diff --git a/fpdf/fonts.py b/fpdf/fonts.py index ecc501f13..6328474f6 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -207,8 +207,8 @@ class CoreFont: "emphasis", ) - def __init__(self, fpdf, fontkey, style): - self.i = len(fpdf.fonts) + 1 + def __init__(self, i, fontkey, style): + self.i = i self.type = "core" self.name = CORE_FONTS[fontkey] self.sp = 250 # strikethrough horizontal position diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index eec4badc6..1994487e8 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -364,7 +364,6 @@ def __init__( self._current_draw_context = None self._drawing_graphics_state_registry = GraphicsStateDictRegistry() - # map page numbers to a set of GraphicsState names: self._record_text_quad_points = False self._resource_catalog = ResourceCatalog() @@ -2146,7 +2145,7 @@ def set_font(self, family=None, style: Union[str, TextEmphasis] = "", size=0): f"Use built-in fonts or FPDF.add_font() beforehand" ) # If it's one of the core fonts, add it to self.fonts - self.fonts[fontkey] = CoreFont(self, fontkey, style) + self.fonts[fontkey] = CoreFont(len(self.fonts) + 1, fontkey, style) # Select it self.font_family = family diff --git a/fpdf/svg.py b/fpdf/svg.py index f4c1e3818..bb7b9f9b6 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -919,17 +919,17 @@ def build_group(self, group, pdf_group=None): if child.tag in xmlns_lookup("svg", "defs"): self.handle_defs(child) elif child.tag in xmlns_lookup("svg", "g"): - pdf_group.add_item(self.build_group(child), False) + pdf_group.add_item(self.build_group(child), clone=False) elif child.tag in xmlns_lookup("svg", "path"): - pdf_group.add_item(self.build_path(child), False) + pdf_group.add_item(self.build_path(child), clone=False) elif child.tag in shape_tags: - pdf_group.add_item(self.build_shape(child), False) + pdf_group.add_item(self.build_shape(child), clone=False) elif child.tag in xmlns_lookup("svg", "use"): - pdf_group.add_item(self.build_xref(child), False) + pdf_group.add_item(self.build_xref(child), clone=False) elif child.tag in xmlns_lookup("svg", "image"): - pdf_group.add_item(self.build_image(child), False) + pdf_group.add_item(self.build_image(child), clone=False) elif child.tag in xmlns_lookup("svg", "text"): - pdf_group.add_item(self.build_text(child), False) + pdf_group.add_item(self.build_text(child), clone=False) else: LOGGER.warning( "Ignoring unsupported SVG tag: <%s> (contributions are welcome to add support for it)", @@ -1014,14 +1014,14 @@ def build_text(self, text): raise NotImplementedError( '"transform" defined on is currently not supported (but contributions are welcome!)' ) - font_family = text.attrib.get("font-family") - font_size = text.attrib.get("font-size") - # TODO: reuse code from line_break & text_region modules. - # We could either: - # 1. handle text regions in this module (svg), with a dedicated SVGText class. - # 2. handle text regions in the drawing module, maybe by defining a PaintedPath.text() method. - # This may be the best approach, as we would benefit from the global transformation performed in SVGObject.transform_to_rect_viewport() - svg_text = None + svg_text = SVGText( + text=text.text, + x=float(text.attrib.get("x", "0")), + y=float(text.attrib.get("y", "0")), + font_family=text.attrib.get("font-family"), + font_size=text.attrib.get("font-size"), + svg_obj=self, + ) self.update_xref(text.attrib.get("id"), svg_text) return svg_text @@ -1061,6 +1061,40 @@ def build_image(self, image): return svg_image +class SVGText(NamedTuple): + text: str + x: Number + y: Number + font_family: str + font_size: Number + svg_obj: SVGObject + + def __deepcopy__(self, _memo): + # Defining this method is required to avoid the .svg_obj reference to be cloned: + return SVGText( + text=self.text, + x=self.x, + y=self.y, + font_family=self.font_family, + font_size=self.font_size, + svg_obj=self.svg_obj, + ) + + @force_nodocument + def render(self, _gsd_registry, _style, last_item, initial_point): + # TODO: + # * handle font_family & font_size + # * invoke current_font.encode_text(self.text) + # * set default font if not font set? + # We need to perform a mirror transform AND invert the Y-axis coordinates, + # so that the text is not horizontally mirrored, + # due to the transformation made by DrawingContext._setup_render_prereqs(): + stream_content = ( + f"q 1 0 0 -1 0 0 cm BT {self.x:.2f} {-self.y:.2f} Td ({self.text}) Tj ET Q" + ) + return stream_content, last_item, initial_point + + class SVGImage(NamedTuple): href: str x: Number diff --git a/test/svg/generated_pdf/text-samples.pdf b/test/svg/generated_pdf/text-samples.pdf index 8124b0b1f2689e7e649bae89e632024875c74d59..02ec1dedeebe012bef16b1c51b8405f9c39c2604 100644 GIT binary patch delta 395 zcmcb~d7pDb1!KL15tp4ES8+*EYGN)|#hli@(_DuPL|nfA?J8W(ojl2`HitV%ASPq8 zu=pjfwE|i4N`I>-hb$3W5_5=Wrsd9uH!P=gFz7F5W4^lC?2z;Hiha+OgKwy4x?g-$ za?P<%YRZd<8=p?_`DV@9T6o4n*g!@&d&)MRK#^OQV$X0yme=c0AJ1p&zu(*u_EJ<&w?S#qnnn1m(?YuQU{1EuHEVEVlik z`B#hluPPBAC#y2`Pu|D0a&j(nQ;>zFf&mC9SXp=H?h;=0>I%ViqO_ zlO0)Xqs<)6oLnp{U5w2Q+$@ZYEes9K4b04p-GGFNg`26JjX5C|v5H~7rUjZE;wwCk66%Q=dspOb+R>6KNpw2i$ZjCtb#%8 z + Text without any attributes + My - cat + cat is - Grumpy! + Grumpy! + + Bottom right From aaa3e8082563da3cbb4c7a82ee2da6177d91cc65 Mon Sep 17 00:00:00 2001 From: Lucas Cimon <925560+Lucas-C@users.noreply.github.com> Date: Fri, 24 Jan 2025 10:16:19 +0100 Subject: [PATCH 3/3] Introducing TextRendererMixin --- .../continuous-integration-workflow.yml | 2 +- docs/Development.md | 2 +- docs/Internals.md | 1 + docs/pdoc/config.mako | 7 + fpdf/__init__.py | 2 +- fpdf/fonts.py | 2 +- fpdf/fpdf.py | 789 +----------------- fpdf/image_parsing.py | 21 +- fpdf/svg.py | 36 +- fpdf/text_region.py | 2 +- fpdf/text_renderer.py | 784 +++++++++++++++++ test/svg/generated_pdf/text-samples.pdf | Bin 1247 -> 0 bytes test/svg/parameters.py | 4 +- test/text/clip_text_modes.pdf | Bin 20630 -> 22474 bytes test/text/test_text_mode.py | 4 +- tox.ini | 2 +- 16 files changed, 863 insertions(+), 795 deletions(-) create mode 100644 docs/pdoc/config.mako create mode 100644 fpdf/text_renderer.py delete mode 100644 test/svg/generated_pdf/text-samples.pdf diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index d472efc83..69d004cfa 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -149,7 +149,7 @@ jobs: sed -i "s/author:.*/author: v$(python setup.py -V 2>/dev/null)/" mkdocs.yml cp tutorial/notebook.ipynb docs/ mkdocs build - pdoc --html -o public/ fpdf --config "git_link_template='https://github.com/py-pdf/fpdf2/blob/{commit}/{path}#L{start_line}-L{end_line}'" + pdoc --html -o public/ fpdf --template-dir docs/pdoc scripts/add_pdoc_to_search_index.py - name: Build contributors map 🗺️ # As build_contributors_html_page.py can hang due to GitHub rate-limiting, we only execute this on master for now diff --git a/docs/Development.md b/docs/Development.md index 90280d648..774e2c9a7 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -240,7 +240,7 @@ To preview the Markdown documentation, launch a local rendering server with: To preview the API documentation, launch a local rendering server with: - pdoc --html -o public/ fpdf --http : + pdoc --html -o public/ fpdf --template-dir docs/pdoc --http : ## PDF spec & new features The **PDF 1.7 spec** is available on Adobe website: diff --git a/docs/Internals.md b/docs/Internals.md index bd5d46d33..8c4c96319 100644 --- a/docs/Internals.md +++ b/docs/Internals.md @@ -94,4 +94,5 @@ drawing.py & svg.py packages ## Text shaping ? ++ add a diagram of the main links between modules/classes --> diff --git a/docs/pdoc/config.mako b/docs/pdoc/config.mako new file mode 100644 index 000000000..645aa371c --- /dev/null +++ b/docs/pdoc/config.mako @@ -0,0 +1,7 @@ +<%! + # pdoc configuration + # * Doc: https://pdoc3.github.io/pdoc/doc/pdoc/#custom-templates + # * Defaults: https://github.com/pdoc3/pdoc/blob/master/pdoc/templates/config.mako + git_link_template = 'https://github.com/py-pdf/fpdf2/blob/{commit}/{path}#L{start_line}-L{end_line}' + show_inherited_members = True +%> \ No newline at end of file diff --git a/fpdf/__init__.py b/fpdf/__init__.py index b1f2a092d..13bacb918 100644 --- a/fpdf/__init__.py +++ b/fpdf/__init__.py @@ -24,12 +24,12 @@ from .fpdf import ( FPDF, TitleStyle, - FPDF_FONT_DIR as _FPDF_FONT_DIR, FPDF_VERSION as _FPDF_VERSION, ) from .html import HTMLMixin, HTML2FPDF from .prefs import ViewerPreferences from .template import Template, FlexTemplate +from .text_renderer import FPDF_FONT_DIR as _FPDF_FONT_DIR from .util import get_scale_factor try: diff --git a/fpdf/fonts.py b/fpdf/fonts.py index 6328474f6..167de238f 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -33,7 +33,7 @@ def __deepcopy__(self, _memo): from .deprecation import get_stack_level from .drawing import convert_to_device_color, DeviceGray, DeviceRGB -from .enums import FontDescriptorFlags, TextEmphasis, Align +from .enums import Align, FontDescriptorFlags, TextEmphasis from .syntax import Name, PDFObject from .util import escape_parens diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 1994487e8..49d294b25 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -21,11 +21,9 @@ from contextlib import contextmanager from datetime import datetime, timezone from functools import wraps -from math import isclose from numbers import Number -from os.path import splitext from pathlib import Path -from typing import Callable, Dict, Iterator, NamedTuple, Optional, Union +from typing import Callable, Dict, NamedTuple, Optional, Union try: from cryptography.hazmat.primitives.serialization import pkcs12 @@ -52,7 +50,6 @@ class Image: PDFAnnotation, PDFEmbeddedFile, ) -from .bidi import BidiParagraph, auto_detect_base_direction from .deprecation import ( WarnOnDeprecatedModuleAttributes, get_stack_level, @@ -87,16 +84,14 @@ class Image: PathPaintRule, PDFResourceType, RenderStyle, - TextDirection, - TextEmphasis, TextMarkupType, TextMode, WrapMode, XPos, YPos, ) -from .errors import FPDFException, FPDFPageFormatException, FPDFUnicodeEncodingException -from .fonts import CoreFont, CORE_FONTS, FontFace, TextStyle, TitleStyle, TTFFont +from .errors import FPDFException, FPDFPageFormatException +from .fonts import TextStyle, TitleStyle from .graphics_state import GraphicsStateMixin from .html import HTML2FPDF from .image_datastructures import ( @@ -113,7 +108,6 @@ class Image: ) from .linearization import LinearizedOutputProducer from .line_break import ( - Fragment, MultiLineBreak, TextLine, TotalPagesSubstitutionFragment, @@ -134,8 +128,9 @@ class Image: from .syntax import DestinationXYZ, PDFArray, PDFDate from .table import Table, draw_box_borders from .text_region import TextRegionMixin, TextColumns +from .text_renderer import TextRendererMixin from .transitions import Transition -from .unicode_script import UnicodeScript, get_unicode_script +from .unicode_script import get_unicode_script from .util import get_scale_factor, Padding # Public global variables: @@ -151,8 +146,6 @@ class Image: # Private global variables: LOGGER = logging.getLogger(__name__) -HERE = Path(__file__).resolve().parent -FPDF_FONT_DIR = HERE / "font" LAYOUT_ALIASES = { "default": None, "single": PageLayout.SINGLE_PAGE, @@ -219,7 +212,7 @@ def wrapper(self, *args, **kwargs): return wrapper -class FPDF(GraphicsStateMixin, TextRegionMixin): +class FPDF(GraphicsStateMixin, TextRendererMixin, TextRegionMixin): "PDF Generation class" MARKDOWN_BOLD_MARKER = "**" MARKDOWN_ITALICS_MARKER = "__" @@ -277,8 +270,6 @@ def __init__( """ # array of PDFPage objects starting at index 1: self.pages: Dict[int, PDFPage] = {} - self.fonts = {} # map font string keys to an instance of CoreFont or TTFFont - # map page numbers to a set of font indices: self.links = {} # array of Destination objects starting at index 1 self.embedded_files = [] # array of PDFEmbeddedFile self.image_cache = ImageCache() @@ -309,31 +300,11 @@ def __init__( self.title = None self.section_title_styles = {} # level -> TextStyle - self.core_fonts_encoding = "latin-1" - "Font encoding, Latin-1 by default" - # Replace these fonts with these core fonts - self.font_aliases = { - "arial": "helvetica", - "couriernew": "courier", - "timesnewroman": "times", - } # Scale factor self.k = get_scale_factor(unit) - # Graphics state variables defined as properties by GraphicsStateMixin. - # We set their default values here. - self.font_family = "" # current font family - # current font style (BOLD/ITALICS - does not handle UNDERLINE nor STRIKETHROUGH): - self.font_style = "" - self.underline = False - self.strikethrough = False - self.font_size_pt = 12 # current font size in points - self.font_stretching = 100 # current font stretching - self.char_spacing = 0 # current character spacing - self.current_font = None # None or an instance of CoreFont or TTFFont self.draw_color = self.DEFAULT_DRAW_COLOR self.fill_color = self.DEFAULT_FILL_COLOR - self.text_color = self.DEFAULT_TEXT_COLOR self.page_background = None self.dash_pattern = dict(dash=0, gap=0, phase=0) self.line_width = 0.567 / self.k # line width (0.2 mm) @@ -359,8 +330,6 @@ def __init__( self.pdf_version = "1.3" # Set default PDF version No. self.creation_date = datetime.now(timezone.utc) self._security_handler = None - self._fallback_font_ids = [] - self._fallback_font_exact_match = False self._current_draw_context = None self._drawing_graphics_state_registry = GraphicsStateDictRegistry() @@ -439,20 +408,6 @@ def write_html(self, text, *args, **kwargs): def _set_min_pdf_version(self, version): self.pdf_version = max(self.pdf_version, version) - @property - def emphasis(self) -> TextEmphasis: - "The current text emphasis: bold, italics, underline and/or strikethrough." - font_style = self.font_style - if self.strikethrough: - font_style += "S" - if self.underline: - font_style += "U" - return TextEmphasis.coerce(font_style) - - @property - def is_ttf_font(self) -> bool: - return self.current_font and self.current_font.type == "TTF" - @property def page_mode(self) -> PageMode: return self._page_mode @@ -606,85 +561,6 @@ def set_display_mode(self, zoom, layout="continuous"): raise FPDFException(f"Incorrect zoom display mode: {zoom}") self.page_layout = LAYOUT_ALIASES.get(layout, layout) - def set_text_shaping( - self, - use_shaping_engine: bool = True, - features: dict = None, - direction: Union[str, TextDirection] = None, - script: str = None, - language: str = None, - ): - """ - Enable or disable text shaping engine when rendering text. - If features, direction, script or language are not specified the shaping engine will try - to guess the values based on the input text. - - Args: - use_shaping_engine: enable or disable the use of the shaping engine to process the text - features: a dictionary containing 4 digit OpenType features and whether each feature - should be enabled or disabled - example: features={"kern": False, "liga": False} - direction: the direction the text should be rendered, either "ltr" (left to right) - or "rtl" (right to left). - script: a valid OpenType script tag like "arab" or "latn" - language: a valid OpenType language tag like "eng" or "fra" - """ - if not use_shaping_engine: - self.text_shaping = None - return - - try: - # pylint: disable=import-outside-toplevel, unused-import - import uharfbuzz - except ImportError as exc: - raise FPDFException( - "The uharfbuzz package could not be imported, but is required for text shaping. Try: pip install uharfbuzz" - ) from exc - - # - # Features must be a dictionary contaning opentype features and a boolean flag - # stating whether the feature should be enabled or disabled. - # - # e.g. features={"liga": True, "kern": False} - # - # https://harfbuzz.github.io/shaping-opentype-features.html - # - - if features and not isinstance(features, dict): - raise FPDFException( - "Features must be a dictionary. See text shaping documentation" - ) - if not features: - features = {} - - # Buffer properties (direction, script and language) - # if the properties are not provided, Harfbuzz "guessing" logic is used. - # https://harfbuzz.github.io/setting-buffer-properties.html - # Valid harfbuzz directions are ltr (left to right), rtl (right to left), - # ttb (top to bottom) or btt (bottom to top) - - text_direction = None - if direction: - text_direction = ( - direction - if isinstance(direction, TextDirection) - else TextDirection.coerce(direction) - ) - if text_direction not in [TextDirection.LTR, TextDirection.RTL]: - raise FPDFException( - "FPDF2 only accept ltr (left to right) or rtl (right to left) directions for now." - ) - - self.text_shaping = { - "use_shaping_engine": True, - "features": features, - "direction": text_direction, - "script": script, - "language": language, - "fragment_direction": None, - "paragraph_direction": None, - } - @property def page_layout(self): return self._page_layout @@ -800,30 +676,6 @@ def set_xmp_metadata(self, xmp_metadata): if xmp_metadata: self._set_min_pdf_version("1.4") - def set_doc_option(self, opt, value): - """ - Defines a document option. - - Args: - opt (str): name of the option to set - value (str) option value - - .. deprecated:: 2.4.0 - Simply set the `FPDF.core_fonts_encoding` property as a replacement. - """ - warnings.warn( - ( - "set_doc_option() is deprecated since v2.4.0 " - "and will be removed in a future release. " - "Simply set the `.core_fonts_encoding` property as a replacement." - ), - DeprecationWarning, - stacklevel=get_stack_level(), - ) - if opt != "core_fonts_encoding": - raise FPDFException(f'Unknown document option "{opt}"') - self.core_fonts_encoding = value - def set_image_filter(self, image_filter): """ Args: @@ -1055,9 +907,7 @@ def _beginpage( page = self.pages[self.page] self.x = self.l_margin self.y = self.t_margin - self.font_family = "" - self.font_stretching = 100 - self.char_spacing = 0 + self.set_new_page_font_settings() if same: if orientation or format: raise ValueError( @@ -1138,41 +988,6 @@ def set_fill_color(self, r, g=-1, b=-1): if self.page > 0: self._out(self.fill_color.serialize().lower()) - def set_text_color(self, r, g=-1, b=-1): - """ - Defines the color used for text. - Accepts either a single greyscale value, 3 values as RGB components, a single `#abc` or `#abcdef` hexadecimal color string, - or an instance of `fpdf.drawing.DeviceCMYK`, `fpdf.drawing.DeviceRGB` or `fpdf.drawing.DeviceGray`. - The method can be called before the first page is created and the value is retained from page to page. - - Args: - r (int, tuple, fpdf.drawing.DeviceGray, fpdf.drawing.DeviceRGB): if `g` and `b` are given, this indicates the red component. - Else, this indicates the grey level. The value must be between 0 and 255. - g (int): green component (between 0 and 255) - b (int): blue component (between 0 and 255) - """ - self.text_color = convert_to_device_color(r, g, b) - - def get_string_width(self, s, normalized=False, markdown=False): - """ - Returns the length of a string in user unit. A font must be selected. - The value is calculated with stretching and spacing. - - Note that the width of a cell has some extra padding added to this width, - on the left & right sides, equal to the .c_margin property. - - Args: - s (str): the string whose length is to be computed. - normalized (bool): whether normalization needs to be performed on the input string. - markdown (bool): indicates if basic markdown support is enabled - """ - # normalized is parameter for internal use - s = s if normalized else self.normalize_text(s) - w = 0 - for frag in self._preload_bidirectional_text(s, markdown): - w += frag.get_width() - return w - def set_line_width(self, width): """ Defines the line width of all stroking operations (lines, rectangles and cell borders). @@ -1261,8 +1076,6 @@ def drawing_context(self, debug_stream=None): self._resource_catalog.add( PDFResourceType.X_OBJECT, int(match.group(1)), self.page ) - # Once we handle text-rendering SVG tags (cf. PR #1029), - # we should also detect fonts used and add them to the resource catalog self._out(rendered) # The drawing API makes use of features (notably transparency and blending modes) that were introduced in PDF 1.4: @@ -1995,247 +1808,6 @@ def bezier(self, point_list, closed=False, style=None): ctxt.add_item(path) - def add_font(self, family=None, style="", fname=None, uni="DEPRECATED"): - """ - Imports a TrueType or OpenType font and makes it available - for later calls to the `FPDF.set_font()` method. - - You will find more information on the "Unicode" documentation page. - - Args: - family (str): optional name of the font family. Used as a reference for `FPDF.set_font()`. - If not provided, use the base name of the `fname` font path, without extension. - style (str): font style. "" for regular, include 'B' for bold, and/or 'I' for italic. - fname (str): font file name. You can specify a relative or full path. - If the file is not found, it will be searched in `FPDF_FONT_DIR`. - uni (bool): [**DEPRECATED since 2.5.1**] unused - """ - if not fname: - raise ValueError('"fname" parameter is required') - - ext = splitext(str(fname))[1].lower() - if ext not in (".otf", ".otc", ".ttf", ".ttc"): - raise ValueError( - f"Unsupported font file extension: {ext}." - " add_font() used to accept .pkl file as input, but for security reasons" - " this feature is deprecated since v2.5.1 and has been removed in v2.5.3." - ) - - if uni != "DEPRECATED": - warnings.warn( - ( - '"uni" parameter is deprecated since v2.5.1, ' - "unused and will soon be removed" - ), - DeprecationWarning, - stacklevel=get_stack_level(), - ) - - style = "".join(sorted(style.upper())) - if any(letter not in "BI" for letter in style): - raise ValueError( - f"Unknown style provided (only B & I letters are allowed): {style}" - ) - - for parent in (".", FPDF_FONT_DIR): - if not parent: - continue - - if (Path(parent) / fname).exists(): - font_file_path = Path(parent) / fname - break - else: - raise FileNotFoundError(f"TTF Font file not found: {fname}") - - if family is None: - family = font_file_path.stem - - fontkey = f"{family.lower()}{style}" - # Check if font already added or one of the core fonts - if fontkey in self.fonts or fontkey in CORE_FONTS: - warnings.warn( - f"Core font or font already added '{fontkey}': doing nothing", - stacklevel=get_stack_level(), - ) - return - - self.fonts[fontkey] = TTFFont(self, font_file_path, fontkey, style) - - def set_font(self, family=None, style: Union[str, TextEmphasis] = "", size=0): - """ - Sets the font used to print character strings. - It is mandatory to call this method at least once before printing text. - - Default encoding is not specified, but all text writing methods accept only - unicode for external fonts and one byte encoding for standard. - - Standard fonts use `Latin-1` encoding by default, but Windows - encoding `cp1252` (Western Europe) can be used with - `self.core_fonts_encoding = encoding`. - - The font specified is retained from page to page. - The method can be called before the first page is created. - - Args: - family (str): name of a font added with `FPDF.add_font`, - or name of one of the 14 standard "PostScript" fonts: - Courier (fixed-width), Helvetica (sans serif), Times (serif), - Symbol (symbolic) or ZapfDingbats (symbolic) - If an empty string is provided, the current family is retained. - style (str, fpdf.enums.TextEmphasis): empty string (by default) or a combination - of one or several letters among B (bold), I (italic), S (strikethrough) and U (underline). - Bold and italic styles do not apply to Symbol and ZapfDingbats fonts. - size (float): in points. The default value is the current size. - """ - if not family: - family = self.font_family - - family = family.lower() - if isinstance(style, TextEmphasis): - style = style.style - style = "".join(sorted(style.upper())) - if any(letter not in "BISU" for letter in style): - raise ValueError( - f"Unknown style provided (only B/I/S/U letters are allowed): {style}" - ) - if "U" in style: - self.underline = True - style = style.replace("U", "") - else: - self.underline = False - if "S" in style: - self.strikethrough = True - style = style.replace("S", "") - else: - self.strikethrough = False - - if family in self.font_aliases and family + style not in self.fonts: - warnings.warn( - f"Substituting font {family} by core font {self.font_aliases[family]}" - " - This is deprecated since v2.7.8, and will soon be removed", - DeprecationWarning, - stacklevel=get_stack_level(), - ) - family = self.font_aliases[family] - elif family in ("symbol", "zapfdingbats") and style: - warnings.warn( - f"Built-in font {family} only has a single 'style' " - "and can't be bold or italic", - stacklevel=get_stack_level(), - ) - style = "" - - if not size: - size = self.font_size_pt - - # Test if font is already selected - if ( - self.font_family == family - and self.font_style == style - and isclose(self.font_size_pt, size) - ): - return - - # Test if used for the first time - fontkey = family + style - if fontkey not in self.fonts: - if fontkey not in CORE_FONTS: - raise FPDFException( - f"Undefined font: {fontkey} - " - f"Use built-in fonts or FPDF.add_font() beforehand" - ) - # If it's one of the core fonts, add it to self.fonts - self.fonts[fontkey] = CoreFont(len(self.fonts) + 1, fontkey, style) - - # Select it - self.font_family = family - self.font_style = style - self.font_size_pt = size - self.current_font = self.fonts[fontkey] - if self.page > 0: - self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET") - self._resource_catalog.add( - PDFResourceType.FONT, self.current_font.i, self.page - ) - - def set_font_size(self, size): - """ - Configure the font size in points - - Args: - size (float): font size in points - """ - if isclose(self.font_size_pt, size): - return - self.font_size_pt = size - if self.page > 0: - if not self.current_font: - raise FPDFException( - "Cannot set font size: a font must be selected first" - ) - self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET") - self._resource_catalog.add( - PDFResourceType.FONT, self.current_font.i, self.page - ) - - def set_char_spacing(self, spacing): - """ - Sets horizontal character spacing. - A positive value increases the space between characters, a negative value - reduces it (which may result in glyph overlap). - By default, no spacing is set (which is equivalent to a value of 0). - - Args: - spacing (float): horizontal spacing in document units - """ - if self.char_spacing == spacing: - return - self.char_spacing = spacing - if self.page > 0: - self._out(f"BT {spacing:.2f} Tc ET") - - def set_stretching(self, stretching): - """ - Sets horizontal font stretching. - By default, no stretching is set (which is equivalent to a value of 100). - - Args: - stretching (float): horizontal stretching (scaling) in percents. - """ - if self.font_stretching == stretching: - return - self.font_stretching = stretching - if self.page > 0: - self._out(f"BT {stretching:.2f} Tz ET") - - def set_fallback_fonts(self, fallback_fonts, exact_match=True): - """ - Allows you to specify a list of fonts to be used if any character is not available on the font currently set. - Detailed documentation: https://py-pdf.github.io/fpdf2/Unicode.html#fallback-fonts - - Args: - fallback_fonts: sequence of fallback font IDs - exact_match (bool): when a glyph cannot be rendered uing the current font, - fpdf2 will look for a fallback font matching the current character emphasis (bold/italics). - If it does not find such matching font, and `exact_match` is True, no fallback font will be used. - If it does not find such matching font, and `exact_match` is False, a fallback font will still be used. - To get even more control over this logic, you can also override `FPDF.get_fallback_font()` - """ - fallback_font_ids = [] - for fallback_font in fallback_fonts: - found = False - for fontkey in self.fonts: - # will add all font styles on the same family - if fontkey.replace("B", "").replace("I", "") == fallback_font.lower(): - fallback_font_ids.append(fontkey) - found = True - if not found: - raise FPDFException( - f"Undefined fallback font: {fallback_font} - Use FPDF.add_font() beforehand" - ) - self._fallback_font_ids = tuple(fallback_font_ids) - self._fallback_font_exact_match = exact_match - def add_link(self, y=0, x=0, page=-1, zoom="null"): """ Creates a new internal link and returns its identifier. @@ -3160,6 +2732,8 @@ def cell( center=center, ) + # pylint: disable=fixme + # TODO: extract part of this in TextRendererMixin, as well as _do_underline & _do_strikethrough def _render_styled_text_line( self, text_line: TextLine, @@ -3480,260 +3054,6 @@ def _add_quad_points(self, x, y, w, h): ] ) - def _preload_bidirectional_text(self, text, markdown): - """ " - Break the text into bidirectional segments and preload font styles for each fragment - """ - if not self.text_shaping: - return self._preload_font_styles(text, markdown) - paragraph_direction = ( - self.text_shaping["direction"] - if self.text_shaping["direction"] - else auto_detect_base_direction(text) - ) - - paragraph = BidiParagraph(text=text, base_direction=paragraph_direction) - directional_segments = paragraph.get_bidi_fragments() - self.text_shaping["paragraph_direction"] = paragraph.base_direction - - fragments = [] - for bidi_text, bidi_direction in directional_segments: - self.text_shaping["fragment_direction"] = bidi_direction - fragments += self._preload_font_styles(bidi_text, markdown) - return tuple(fragments) - - def _preload_font_styles(self, text, markdown): - """ - When Markdown styling is enabled, we require secondary fonts - to ender text in bold & italics. - This function ensure that those fonts are available. - It needs to perform Markdown parsing, - so we return the resulting `styled_txt_frags` tuple - to avoid repeating this processing later on. - """ - if not text: - return tuple() - prev_font_style = self.font_style - if self.underline: - prev_font_style += "U" - if self.strikethrough: - prev_font_style += "S" - styled_txt_frags = tuple(self._parse_chars(text, markdown)) - if markdown: - page = self.page - # We set the current to page to zero so that - # set_font() does not produce any text object on the stream buffer: - self.page = 0 - if any(frag.font_style == "B" for frag in styled_txt_frags): - # Ensuring bold font is supported: - self.set_font(style="B") - if any(frag.font_style == "I" for frag in styled_txt_frags): - # Ensuring italics font is supported: - self.set_font(style="I") - if any(frag.font_style == "BI" for frag in styled_txt_frags): - # Ensuring bold italics font is supported: - self.set_font(style="BI") - if any(frag.font_style == "" for frag in styled_txt_frags): - # Ensuring base font is supported: - self.set_font(style="") - for frag in styled_txt_frags: - frag.font = self.fonts[frag.font_family + frag.font_style] - # Restoring initial style: - self.set_font(style=prev_font_style) - self.page = page - return styled_txt_frags - - def get_fallback_font(self, char, style=""): - """ - Returns which fallback font has the requested glyph. - This method can be overriden to provide more control than the `select_mode` parameter - of `FPDF.set_fallback_fonts()` provides. - """ - emphasis = TextEmphasis.coerce(style) - fonts_with_char = [ - font_id - for font_id in self._fallback_font_ids - if ord(char) in self.fonts[font_id].cmap - ] - if not fonts_with_char: - return None - font_with_matching_emphasis = next( - (font for font in fonts_with_char if self.fonts[font].emphasis == emphasis), - None, - ) - if font_with_matching_emphasis: - return font_with_matching_emphasis - if self._fallback_font_exact_match: - return None - return fonts_with_char[0] - - def _parse_chars(self, text: str, markdown: bool) -> Iterator[Fragment]: - "Split text into fragments" - if not markdown and not self.text_shaping and not self._fallback_font_ids: - if self.str_alias_nb_pages: - for seq, fragment_text in enumerate( - text.split(self.str_alias_nb_pages) - ): - if seq > 0: - yield TotalPagesSubstitutionFragment( - self.str_alias_nb_pages, - self._get_current_graphics_state(), - self.k, - ) - if fragment_text: - yield Fragment( - fragment_text, self._get_current_graphics_state(), self.k - ) - return - - yield Fragment(text, self._get_current_graphics_state(), self.k) - return - txt_frag, in_bold, in_italics, in_underline = ( - [], - "B" in self.font_style, - "I" in self.font_style, - bool(self.underline), - ) - current_fallback_font = None - current_text_script = None - - def frag(): - nonlocal txt_frag, current_fallback_font, current_text_script - gstate = self._get_current_graphics_state() - gstate["font_style"] = ("B" if in_bold else "") + ( - "I" if in_italics else "" - ) - gstate["underline"] = in_underline - if current_fallback_font: - gstate["font_family"] = "".join( - c for c in current_fallback_font if c.islower() - ) - gstate["font_style"] = "".join( - c for c in current_fallback_font if c.isupper() - ) - gstate["current_font"] = self.fonts[current_fallback_font] - current_fallback_font = None - current_text_script = None - fragment = Fragment( - txt_frag, - gstate, - self.k, - ) - txt_frag = [] - return fragment - - if self.is_ttf_font: - font_glyphs = self.current_font.cmap - else: - font_glyphs = [] - num_escape_chars = 0 - - while text: - is_marker = text[:2] in ( - self.MARKDOWN_BOLD_MARKER, - self.MARKDOWN_ITALICS_MARKER, - self.MARKDOWN_UNDERLINE_MARKER, - ) - half_marker = text[0] - text_script = get_unicode_script(text[0]) - if text_script not in ( - UnicodeScript.COMMON, - UnicodeScript.UNKNOWN, - current_text_script, - ): - if txt_frag and current_text_script: - yield frag() - current_text_script = text_script - - if self.str_alias_nb_pages: - if text[: len(self.str_alias_nb_pages)] == self.str_alias_nb_pages: - if txt_frag: - yield frag() - gstate = self._get_current_graphics_state() - gstate["font_style"] = ("B" if in_bold else "") + ( - "I" if in_italics else "" - ) - gstate["underline"] = in_underline - yield TotalPagesSubstitutionFragment( - self.str_alias_nb_pages, - gstate, - self.k, - ) - text = text[len(self.str_alias_nb_pages) :] - continue - - # Check that previous & next characters are not identical to the marker: - if markdown: - if ( - is_marker - and (not txt_frag or txt_frag[-1] != half_marker) - and (len(text) < 3 or text[2] != half_marker) - ): - txt_frag = ( - txt_frag[: -((num_escape_chars + 1) // 2)] - if num_escape_chars > 0 - else txt_frag - ) - if num_escape_chars % 2 == 0: - if txt_frag: - yield frag() - if text[:2] == self.MARKDOWN_BOLD_MARKER: - in_bold = not in_bold - if text[:2] == self.MARKDOWN_ITALICS_MARKER: - in_italics = not in_italics - if text[:2] == self.MARKDOWN_UNDERLINE_MARKER: - in_underline = not in_underline - text = text[2:] - continue - num_escape_chars = ( - num_escape_chars + 1 - if text[0] == self.MARKDOWN_ESCAPE_CHARACTER - else 0 - ) - is_link = self.MARKDOWN_LINK_REGEX.match(text) - if is_link: - link_text, link_dest, text = is_link.groups() - if txt_frag: - yield frag() - gstate = self._get_current_graphics_state() - gstate["underline"] = self.MARKDOWN_LINK_UNDERLINE - if self.MARKDOWN_LINK_COLOR: - gstate["text_color"] = self.MARKDOWN_LINK_COLOR - try: - page = int(link_dest) - link_dest = self.add_link(page=page) - except ValueError: - pass - yield Fragment( - list(link_text), - gstate, - self.k, - link=link_dest, - ) - continue - if self.is_ttf_font and text[0] != "\n" and not ord(text[0]) in font_glyphs: - style = ("B" if in_bold else "") + ("I" if in_italics else "") - fallback_font = self.get_fallback_font(text[0], style) - if fallback_font: - if fallback_font == current_fallback_font: - txt_frag.append(text[0]) - text = text[1:] - continue - if txt_frag: - yield frag() - current_fallback_font = fallback_font - txt_frag.append(text[0]) - text = text[1:] - continue - if current_fallback_font: - if txt_frag: - yield frag() - current_fallback_font = None - txt_frag.append(text[0]) - text = text[1:] - if txt_frag: - yield frag() - def will_page_break(self, height): """ Let you know if adding an element will trigger a page break, @@ -4365,7 +3685,9 @@ def image( stacklevel=get_stack_level(), ) - name, img, info = preload_image(self.image_cache, name, dims) + name, img, info = preload_image( + name, image_cache=self.image_cache, dims=dims, font_mgr=self + ) if isinstance(info, VectorImageInfo): return self._vector_image( name, img, info, x, y, w, h, link, title, alt_text, keep_aspect_ratio @@ -4415,7 +3737,7 @@ def _raster_image( x = self.x if not isinstance(x, Number): - x = self.x_by_align(x, w, h, info, keep_aspect_ratio) + x = self._x_by_align(x, w, h, info, keep_aspect_ratio) if keep_aspect_ratio: x, y, w, h = info.scale_inside_box(x, y, w, h) if self.oversized_images and info["usages"] == 1 and not dims: @@ -4436,7 +3758,7 @@ def _raster_image( self._resource_catalog.add(PDFResourceType.X_OBJECT, info["i"], self.page) return RasterImageInfo(**info, rendered_width=w, rendered_height=h) - def x_by_align(self, x, w, h, img_info, keep_aspect_ratio): + def _x_by_align(self, x, w, h, img_info, keep_aspect_ratio): if keep_aspect_ratio: _, _, w, h = img_info.scale_inside_box(0, 0, w, h) x = Align.coerce(x) @@ -4512,7 +3834,7 @@ def _vector_image( if keep_aspect_ratio: x, y, w, h = info.scale_inside_box(x, y, w, h) if not isinstance(x, Number): - x = self.x_by_align(x, w, h, info, keep_aspect_ratio) + x = self._x_by_align(x, w, h, info, keep_aspect_ratio) _, _, path = svg.transform_to_rect_viewport( scale=1, width=w, height=h, ignore_svg_top_attrs=True @@ -4639,7 +3961,9 @@ def preload_image(self, name, dims=None): DeprecationWarning, stacklevel=get_stack_level(), ) - return preload_image(self.image_cache, name, dims) + return preload_image( + name, image_cache=self.image_cache, dims=dims, font_mgr=self + ) @contextmanager def _marked_sequence(self, **kwargs): @@ -4736,21 +4060,6 @@ def set_xy(self, x, y): self.set_y(y) self.set_x(x) - def normalize_text(self, text): - """Check that text input is in the correct format/encoding""" - # - for TTF unicode fonts: unicode object (utf8 encoding) - # - for built-in fonts: string instances (encoding: latin-1, cp1252) - if not self.is_ttf_font and self.core_fonts_encoding: - try: - return text.encode(self.core_fonts_encoding).decode("latin-1") - except UnicodeEncodeError as error: - raise FPDFUnicodeEncodingException( - text_index=error.start, - character=text[error.start], - font_name=self.font_family + self.font_style, - ) from error - return text - def sign_pkcs12( self, pkcs_filepath, @@ -5317,68 +4626,6 @@ def start_section(self, name, level=0, strict=True): OutlineSection(name, level, self.page, dest, outline_struct_elem) ) - @contextmanager - def use_text_style(self, text_style: TextStyle): - prev_l_margin = None - if text_style: - if text_style.t_margin: - self.ln(text_style.t_margin) - if text_style.l_margin: - if isinstance(text_style.l_margin, (float, int)): - prev_l_margin = self.l_margin - self.l_margin = text_style.l_margin - self.x = self.l_margin - else: - LOGGER.debug( - "Unsupported '%s' value provided as l_margin to .use_text_style()", - text_style.l_margin, - ) - with self.use_font_face(text_style): - yield - if text_style and text_style.b_margin: - self.ln(text_style.b_margin) - if prev_l_margin is not None: - self.l_margin = prev_l_margin - self.x = self.l_margin - - @contextmanager - def use_font_face(self, font_face: FontFace): - """ - Sets the provided `fpdf.fonts.FontFace` in a local context, - then restore font settings back to they were initially. - This method must be used as a context manager using `with`: - - with pdf.use_font_face(FontFace(emphasis="BOLD", color=255, size_pt=42)): - put_some_text() - - Known limitation: in case of a page jump in this local context, - the temporary style may "leak" in the header() & footer(). - """ - if not font_face: - yield - return - 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 self.font_style - ), - font_face.size_pt or self.font_size_pt, - ) - prev_text_color = self.text_color - if font_face.color is not None and font_face.color != self.text_color: - self.set_text_color(font_face.color) - prev_fill_color = self.fill_color - if font_face.fill_color is not None: - self.set_fill_color(font_face.fill_color) - yield - if font_face.fill_color is not None: - self.set_fill_color(prev_fill_color) - self.text_color = prev_text_color - self.set_font(*prev_font) - @check_page @contextmanager def table(self, *args, **kwargs): diff --git a/fpdf/image_parsing.py b/fpdf/image_parsing.py index 55b648b44..115b827a2 100644 --- a/fpdf/image_parsing.py +++ b/fpdf/image_parsing.py @@ -23,6 +23,7 @@ from .errors import FPDFException from .image_datastructures import ImageCache, RasterImageInfo, VectorImageInfo from .svg import SVGObject +from .text_renderer import TextRendererMixin @dataclass @@ -72,7 +73,9 @@ class ImageSettings: LZW_MAX_BITS_PER_CODE = 12 # Maximum code bit width -def preload_image(image_cache: ImageCache, name, dims=None): +def preload_image( + name, image_cache: ImageCache, dims=None, font_mgr: TextRendererMixin = None +): """ Read an image and load it into memory. @@ -94,13 +97,19 @@ def preload_image(image_cache: ImageCache, name, dims=None): # Identify and load SVG data: if str(name).endswith(".svg"): try: - return get_svg_info(name, load_image(str(name)), image_cache=image_cache) + return get_svg_info( + name, load_image(str(name)), font_mgr=font_mgr, image_cache=image_cache + ) except Exception as error: raise ValueError(f"Could not parse file: {name}") from error if isinstance(name, bytes) and _is_svg(name.strip()): - return get_svg_info(name, io.BytesIO(name), image_cache=image_cache) + return get_svg_info( + name, io.BytesIO(name), font_mgr=font_mgr, image_cache=image_cache + ) if isinstance(name, io.BytesIO) and _is_svg(name.getvalue().strip()): - return get_svg_info("vector_image", name, image_cache=image_cache) + return get_svg_info( + "vector_image", name, font_mgr=font_mgr, image_cache=image_cache + ) # Load raster data. if isinstance(name, str): @@ -198,8 +207,8 @@ def is_iccp_valid(iccp, filename): return True -def get_svg_info(filename, img, image_cache): - svg = SVGObject(img.getvalue(), image_cache=image_cache) +def get_svg_info(filename, img, font_mgr: TextRendererMixin, image_cache: ImageCache): + svg = SVGObject(img.getvalue(), font_mgr=font_mgr, image_cache=image_cache) if svg.viewbox: _, _, w, h = svg.viewbox else: diff --git a/fpdf/svg.py b/fpdf/svg.py index bb7b9f9b6..e8805a05c 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -13,8 +13,6 @@ from fontTools.svgLib.path import parse_path from fontTools.pens.basePen import BasePen -from .enums import PathPaintRule - try: from defusedxml.ElementTree import fromstring as parse_xml_str except ImportError: @@ -34,8 +32,10 @@ ClippingPath, Transform, ) +from .enums import PathPaintRule from .image_datastructures import ImageCache, VectorImageInfo from .output import stream_content_for_raster_image +from .text_renderer import TextRendererMixin LOGGER = logging.getLogger(__name__) @@ -636,7 +636,13 @@ def from_file(cls, filename, *args, encoding="utf-8", **kwargs): with open(filename, "r", encoding=encoding) as svgfile: return cls(svgfile.read(), *args, **kwargs) - def __init__(self, svg_text, image_cache: ImageCache = None): + def __init__( + self, + svg_text, + font_mgr: TextRendererMixin = None, + image_cache: ImageCache = None, + ): + self.font_mgr = font_mgr # Needed to render text self.image_cache = image_cache # Needed to render images self.cross_references = {} @@ -826,6 +832,7 @@ def draw_to_page(self, pdf, x=None, y=None, debug_stream=None): debug_stream (io.TextIO): the stream to which rendering debug info will be written. """ + self.font_mgr = pdf # Needed to render text self.image_cache = pdf.image_cache # Needed to render images _, _, path = self.transform_to_page_viewport(pdf) @@ -852,8 +859,10 @@ def handle_defs(self, defs): self.build_path(child) elif child.tag in xmlns_lookup("svg", "image"): self.build_image(child) - elif child.tag in xmlns_lookup("svg", "text"): - self.build_text(child) + # pylint: disable=fixme + # TODO: enable this + # elif child.tag in xmlns_lookup("svg", "text"): + # self.build_text(child) elif child.tag in shape_tags: self.build_shape(child) elif child.tag in xmlns_lookup("svg", "clipPath"): @@ -928,8 +937,10 @@ def build_group(self, group, pdf_group=None): pdf_group.add_item(self.build_xref(child), clone=False) elif child.tag in xmlns_lookup("svg", "image"): pdf_group.add_item(self.build_image(child), clone=False) - elif child.tag in xmlns_lookup("svg", "text"): - pdf_group.add_item(self.build_text(child), clone=False) + # pylint: disable=fixme + # TODO: enable this + # elif child.tag in xmlns_lookup("svg", "text"): + # pdf_group.add_item(self.build_text(child), clone=False) else: LOGGER.warning( "Ignoring unsupported SVG tag: <%s> (contributions are welcome to add support for it)", @@ -1082,10 +1093,17 @@ def __deepcopy__(self, _memo): @force_nodocument def render(self, _gsd_registry, _style, last_item, initial_point): + font_mgr = self.svg_obj and self.svg_obj.font_mgr + if not font_mgr: + raise AssertionError( + "fpdf2 bug - Cannot render a raster image without a SVGObject.font_mgr" + ) + # pylint: disable=fixme # TODO: # * handle font_family & font_size # * invoke current_font.encode_text(self.text) - # * set default font if not font set? + # * set default font to Times/16 if not font set + # * support textLength -> .font_stretching # We need to perform a mirror transform AND invert the Y-axis coordinates, # so that the text is not horizontally mirrored, # due to the transformation made by DrawingContext._setup_render_prereqs(): @@ -1126,7 +1144,7 @@ def render(self, _gsd_registry, _style, last_item, initial_point): # pylint: disable=cyclic-import,import-outside-toplevel from .image_parsing import preload_image - _, _, info = preload_image(image_cache, self.href) + _, _, info = preload_image(self.href, image_cache) if isinstance(info, VectorImageInfo): LOGGER.warning( "Inserting .svg vector graphics in tags is currently not supported (contributions are welcome to add support for it)" diff --git a/fpdf/text_region.py b/fpdf/text_region.py index 4fe9a46ae..3f7ecdb63 100644 --- a/fpdf/text_region.py +++ b/fpdf/text_region.py @@ -246,7 +246,7 @@ 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 + self.name, image_cache=self.region.pdf.image_cache, font_mgr=self.region.pdf ) return self diff --git a/fpdf/text_renderer.py b/fpdf/text_renderer.py new file mode 100644 index 000000000..a27967ca3 --- /dev/null +++ b/fpdf/text_renderer.py @@ -0,0 +1,784 @@ +import logging +import warnings + +from contextlib import contextmanager +from math import isclose +from os.path import splitext +from pathlib import Path +from typing import Iterator, Union + +from .bidi import BidiParagraph, auto_detect_base_direction +from .deprecation import get_stack_level +from .drawing import convert_to_device_color +from .errors import FPDFException, FPDFUnicodeEncodingException +from .fonts import CORE_FONTS, CoreFont, FontFace, TextStyle, TTFFont +from .enums import PDFResourceType, TextDirection, TextEmphasis +from .line_break import Fragment, TotalPagesSubstitutionFragment +from .unicode_script import UnicodeScript, get_unicode_script + +HERE = Path(__file__).resolve().parent +FPDF_FONT_DIR = HERE / "font" +LOGGER = logging.getLogger(__name__) + + +class TextRendererMixin: + """ + Mix-in to be added to FPDF(). + # TODO: add details + """ + + def __init__(self, *args, **kwargs): + self.fonts = {} # map font string keys to an instance of CoreFont or TTFFont + self.core_fonts_encoding = "latin-1" + "Font encoding, Latin-1 by default" + # Replace these fonts with these core fonts + self.font_aliases = { + "arial": "helvetica", + "couriernew": "courier", + "timesnewroman": "times", + } + # Graphics state variables defined as properties by GraphicsStateMixin. + # We set their default values here. + self.font_family = "" # current font family + # current font style (BOLD/ITALICS - does not handle UNDERLINE nor STRIKETHROUGH): + self.font_style = "" + self.underline = False + self.strikethrough = False + self.font_size_pt = 12 # current font size in points + self.font_stretching = 100 # current font stretching + self.char_spacing = 0 # current character spacing + self.current_font = None # None or an instance of CoreFont or TTFFont + self.text_color = self.DEFAULT_TEXT_COLOR + self._fallback_font_ids = [] + self._fallback_font_exact_match = False + # pylint: disable=fixme + # TODO: add self.text_mode + self._record_text_quad_points / ._text_quad_points + super().__init__(*args, **kwargs) + + @property + def emphasis(self) -> TextEmphasis: + "The current text emphasis: bold, italics, underline and/or strikethrough." + font_style = self.font_style + if self.strikethrough: + font_style += "S" + if self.underline: + font_style += "U" + return TextEmphasis.coerce(font_style) + + @property + def is_ttf_font(self) -> bool: + return self.current_font and self.current_font.type == "TTF" + + def set_text_color(self, r, g=-1, b=-1): + """ + Defines the color used for text. + Accepts either a single greyscale value, 3 values as RGB components, a single `#abc` or `#abcdef` hexadecimal color string, + or an instance of `fpdf.drawing.DeviceCMYK`, `fpdf.drawing.DeviceRGB` or `fpdf.drawing.DeviceGray`. + The method can be called before the first page is created and the value is retained from page to page. + + Args: + r (int, tuple, fpdf.drawing.DeviceGray, fpdf.drawing.DeviceRGB): if `g` and `b` are given, this indicates the red component. + Else, this indicates the grey level. The value must be between 0 and 255. + g (int): green component (between 0 and 255) + b (int): blue component (between 0 and 255) + """ + self.text_color = convert_to_device_color(r, g, b) + + def set_font_size(self, size): + """ + Configure the font size in points + + Args: + size (float): font size in points + """ + if isclose(self.font_size_pt, size): + return + self.font_size_pt = size + if self.page > 0: + if not self.current_font: + raise FPDFException( + "Cannot set font size: a font must be selected first" + ) + self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET") + self._resource_catalog.add( + PDFResourceType.FONT, self.current_font.i, self.page + ) + + def set_char_spacing(self, spacing): + """ + Sets horizontal character spacing. + A positive value increases the space between characters, a negative value + reduces it (which may result in glyph overlap). + By default, no spacing is set (which is equivalent to a value of 0). + + Args: + spacing (float): horizontal spacing in document units + """ + if self.char_spacing == spacing: + return + self.char_spacing = spacing + if self.page > 0: + self._out(f"BT {spacing:.2f} Tc ET") + + def set_stretching(self, stretching): + """ + Sets horizontal font stretching. + By default, no stretching is set (which is equivalent to a value of 100). + + Args: + stretching (float): horizontal stretching (scaling) in percents. + """ + if self.font_stretching == stretching: + return + self.font_stretching = stretching + if self.page > 0: + self._out(f"BT {stretching:.2f} Tz ET") + + def set_fallback_fonts(self, fallback_fonts, exact_match=True): + """ + Allows you to specify a list of fonts to be used if any character is not available on the font currently set. + Detailed documentation: https://py-pdf.github.io/fpdf2/Unicode.html#fallback-fonts + + Args: + fallback_fonts: sequence of fallback font IDs + exact_match (bool): when a glyph cannot be rendered uing the current font, + fpdf2 will look for a fallback font matching the current character emphasis (bold/italics). + If it does not find such matching font, and `exact_match` is True, no fallback font will be used. + If it does not find such matching font, and `exact_match` is False, a fallback font will still be used. + To get even more control over this logic, you can also override `FPDF.get_fallback_font()` + """ + fallback_font_ids = [] + for fallback_font in fallback_fonts: + found = False + for fontkey in self.fonts: + # will add all font styles on the same family + if fontkey.replace("B", "").replace("I", "") == fallback_font.lower(): + fallback_font_ids.append(fontkey) + found = True + if not found: + raise FPDFException( + f"Undefined fallback font: {fallback_font} - Use FPDF.add_font() beforehand" + ) + self._fallback_font_ids = tuple(fallback_font_ids) + self._fallback_font_exact_match = exact_match + + @contextmanager + def use_text_style(self, text_style: TextStyle): + prev_l_margin = None + if text_style: + if text_style.t_margin: + self.ln(text_style.t_margin) + if text_style.l_margin: + if isinstance(text_style.l_margin, (float, int)): + prev_l_margin = self.l_margin + self.l_margin = text_style.l_margin + self.x = self.l_margin + else: + LOGGER.debug( + "Unsupported '%s' value provided as l_margin to .use_text_style()", + text_style.l_margin, + ) + with self.use_font_face(text_style): + yield + if text_style and text_style.b_margin: + self.ln(text_style.b_margin) + if prev_l_margin is not None: + self.l_margin = prev_l_margin + self.x = self.l_margin + + @contextmanager + def use_font_face(self, font_face: FontFace): + """ + Sets the provided `fpdf.fonts.FontFace` in a local context, + then restore font settings back to they were initially. + This method must be used as a context manager using `with`: + + with pdf.use_font_face(FontFace(emphasis="BOLD", color=255, size_pt=42)): + put_some_text() + + Known limitation: in case of a page jump in this local context, + the temporary style may "leak" in the header() & footer(). + """ + if not font_face: + yield + return + 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 self.font_style + ), + font_face.size_pt or self.font_size_pt, + ) + prev_text_color = self.text_color + if font_face.color is not None and font_face.color != self.text_color: + self.set_text_color(font_face.color) + prev_fill_color = self.fill_color + if font_face.fill_color is not None: + self.set_fill_color(font_face.fill_color) + yield + if font_face.fill_color is not None: + self.set_fill_color(prev_fill_color) + self.text_color = prev_text_color + self.set_font(*prev_font) + + def set_new_page_font_settings(self): + self.font_family = "" + self.font_stretching = 100 + self.char_spacing = 0 + + def add_font(self, family=None, style="", fname=None, uni="DEPRECATED"): + """ + Imports a TrueType or OpenType font and makes it available + for later calls to the `FPDF.set_font()` method. + + You will find more information on the "Unicode" documentation page. + + Args: + family (str): optional name of the font family. Used as a reference for `FPDF.set_font()`. + If not provided, use the base name of the `fname` font path, without extension. + style (str): font style. "" for regular, include 'B' for bold, and/or 'I' for italic. + fname (str): font file name. You can specify a relative or full path. + If the file is not found, it will be searched in `FPDF_FONT_DIR`. + uni (bool): [**DEPRECATED since 2.5.1**] unused + """ + if not fname: + raise ValueError('"fname" parameter is required') + + ext = splitext(str(fname))[1].lower() + if ext not in (".otf", ".otc", ".ttf", ".ttc"): + raise ValueError( + f"Unsupported font file extension: {ext}." + " add_font() used to accept .pkl file as input, but for security reasons" + " this feature is deprecated since v2.5.1 and has been removed in v2.5.3." + ) + + if uni != "DEPRECATED": + warnings.warn( + ( + '"uni" parameter is deprecated since v2.5.1, ' + "unused and will soon be removed" + ), + DeprecationWarning, + stacklevel=get_stack_level(), + ) + + style = "".join(sorted(style.upper())) + if any(letter not in "BI" for letter in style): + raise ValueError( + f"Unknown style provided (only B & I letters are allowed): {style}" + ) + + for parent in (".", FPDF_FONT_DIR): + if not parent: + continue + if (Path(parent) / fname).exists(): + font_file_path = Path(parent) / fname + break + else: + raise FileNotFoundError(f"TTF Font file not found: {fname}") + + if family is None: + family = font_file_path.stem + + fontkey = f"{family.lower()}{style}" + # Check if font already added or one of the core fonts + if fontkey in self.fonts or fontkey in CORE_FONTS: + warnings.warn( + f"Core font or font already added '{fontkey}': doing nothing", + stacklevel=get_stack_level(), + ) + return + + self.fonts[fontkey] = TTFFont(self, font_file_path, fontkey, style) + + def set_font(self, family=None, style: Union[str, TextEmphasis] = "", size=0): + """ + Sets the font used to print character strings. + It is mandatory to call this method at least once before printing text. + + Default encoding is not specified, but all text writing methods accept only + unicode for external fonts and one byte encoding for standard. + + Standard fonts use `Latin-1` encoding by default, but Windows + encoding `cp1252` (Western Europe) can be used with + `self.core_fonts_encoding = encoding`. + + The font specified is retained from page to page. + The method can be called before the first page is created. + + Args: + family (str): name of a font added with `FPDF.add_font`, + or name of one of the 14 standard "PostScript" fonts: + Courier (fixed-width), Helvetica (sans serif), Times (serif), + Symbol (symbolic) or ZapfDingbats (symbolic) + If an empty string is provided, the current family is retained. + style (str, fpdf.enums.TextEmphasis): empty string (by default) or a combination + of one or several letters among B (bold), I (italic), S (strikethrough) and U (underline). + Bold and italic styles do not apply to Symbol and ZapfDingbats fonts. + size (float): in points. The default value is the current size. + """ + if not family: + family = self.font_family + + family = family.lower() + if isinstance(style, TextEmphasis): + style = style.style + style = "".join(sorted(style.upper())) + if any(letter not in "BISU" for letter in style): + raise ValueError( + f"Unknown style provided (only B/I/S/U letters are allowed): {style}" + ) + if "U" in style: + self.underline = True + style = style.replace("U", "") + else: + self.underline = False + if "S" in style: + self.strikethrough = True + style = style.replace("S", "") + else: + self.strikethrough = False + + if family in self.font_aliases and family + style not in self.fonts: + warnings.warn( + f"Substituting font {family} by core font {self.font_aliases[family]}" + " - This is deprecated since v2.7.8, and will soon be removed", + DeprecationWarning, + stacklevel=get_stack_level(), + ) + family = self.font_aliases[family] + elif family in ("symbol", "zapfdingbats") and style: + warnings.warn( + f"Built-in font {family} only has a single 'style' " + "and can't be bold or italic", + stacklevel=get_stack_level(), + ) + style = "" + + if not size: + size = self.font_size_pt + + # Test if font is already selected + if ( + self.font_family == family + and self.font_style == style + and isclose(self.font_size_pt, size) + ): + return + + # Test if used for the first time + fontkey = family + style + if fontkey not in self.fonts: + if fontkey not in CORE_FONTS: + raise FPDFException( + f"Undefined font: {fontkey} - " + f"Use built-in fonts or FPDF.add_font() beforehand" + ) + # If it's one of the core fonts, add it to self.fonts + self.fonts[fontkey] = CoreFont(len(self.fonts) + 1, fontkey, style) + + # Select it + self.font_family = family + self.font_style = style + self.font_size_pt = size + self.current_font = self.fonts[fontkey] + if self.page > 0: + self._out(f"BT /F{self.current_font.i} {self.font_size_pt:.2f} Tf ET") + self._resource_catalog.add( + PDFResourceType.FONT, self.current_font.i, self.page + ) + + def set_text_shaping( + self, + use_shaping_engine: bool = True, + features: dict = None, + direction: Union[str, TextDirection] = None, + script: str = None, + language: str = None, + ): + """ + Enable or disable text shaping engine when rendering text. + If features, direction, script or language are not specified the shaping engine will try + to guess the values based on the input text. + + Args: + use_shaping_engine: enable or disable the use of the shaping engine to process the text + features: a dictionary containing 4 digit OpenType features and whether each feature + should be enabled or disabled + example: features={"kern": False, "liga": False} + direction: the direction the text should be rendered, either "ltr" (left to right) + or "rtl" (right to left). + script: a valid OpenType script tag like "arab" or "latn" + language: a valid OpenType language tag like "eng" or "fra" + """ + if not use_shaping_engine: + self.text_shaping = None + return + + try: + # pylint: disable=import-outside-toplevel, unused-import + import uharfbuzz + except ImportError as exc: + raise FPDFException( + "The uharfbuzz package could not be imported, but is required for text shaping. Try: pip install uharfbuzz" + ) from exc + + # + # Features must be a dictionary contaning opentype features and a boolean flag + # stating whether the feature should be enabled or disabled. + # + # e.g. features={"liga": True, "kern": False} + # + # https://harfbuzz.github.io/shaping-opentype-features.html + # + + if features and not isinstance(features, dict): + raise FPDFException( + "Features must be a dictionary. See text shaping documentation" + ) + if not features: + features = {} + + # Buffer properties (direction, script and language) + # if the properties are not provided, Harfbuzz "guessing" logic is used. + # https://harfbuzz.github.io/setting-buffer-properties.html + # Valid harfbuzz directions are ltr (left to right), rtl (right to left), + # ttb (top to bottom) or btt (bottom to top) + + text_direction = None + if direction: + text_direction = ( + direction + if isinstance(direction, TextDirection) + else TextDirection.coerce(direction) + ) + if text_direction not in [TextDirection.LTR, TextDirection.RTL]: + raise FPDFException( + "FPDF2 only accept ltr (left to right) or rtl (right to left) directions for now." + ) + + self.text_shaping = { + "use_shaping_engine": True, + "features": features, + "direction": text_direction, + "script": script, + "language": language, + "fragment_direction": None, + "paragraph_direction": None, + } + + def get_string_width(self, s, normalized=False, markdown=False): + """ + Returns the length of a string in user unit. A font must be selected. + The value is calculated with stretching and spacing. + + Note that the width of a cell has some extra padding added to this width, + on the left & right sides, equal to the .c_margin property. + + Args: + s (str): the string whose length is to be computed. + normalized (bool): whether normalization needs to be performed on the input string. + markdown (bool): indicates if basic markdown support is enabled + """ + # normalized is parameter for internal use + s = s if normalized else self.normalize_text(s) + w = 0 + for frag in self._preload_bidirectional_text(s, markdown): + w += frag.get_width() + return w + + def get_fallback_font(self, char, style=""): + """ + Returns which fallback font has the requested glyph. + This method can be overriden to provide more control than the `select_mode` parameter + of `FPDF.set_fallback_fonts()` provides. + """ + emphasis = TextEmphasis.coerce(style) + fonts_with_char = [ + font_id + for font_id in self._fallback_font_ids + if ord(char) in self.fonts[font_id].cmap + ] + if not fonts_with_char: + return None + font_with_matching_emphasis = next( + (font for font in fonts_with_char if self.fonts[font].emphasis == emphasis), + None, + ) + if font_with_matching_emphasis: + return font_with_matching_emphasis + if self._fallback_font_exact_match: + return None + return fonts_with_char[0] + + def normalize_text(self, text): + """Check that text input is in the correct format/encoding""" + # - for TTF unicode fonts: unicode object (utf8 encoding) + # - for built-in fonts: string instances (encoding: latin-1, cp1252) + if not self.is_ttf_font and self.core_fonts_encoding: + try: + return text.encode(self.core_fonts_encoding).decode("latin-1") + except UnicodeEncodeError as error: + raise FPDFUnicodeEncodingException( + text_index=error.start, + character=text[error.start], + font_name=self.font_family + self.font_style, + ) from error + return text + + def _preload_bidirectional_text(self, text, markdown): + """ " + Break the text into bidirectional segments and preload font styles for each fragment + """ + if not self.text_shaping: + return self._preload_font_styles(text, markdown) + paragraph_direction = ( + self.text_shaping["direction"] + if self.text_shaping["direction"] + else auto_detect_base_direction(text) + ) + + paragraph = BidiParagraph(text=text, base_direction=paragraph_direction) + directional_segments = paragraph.get_bidi_fragments() + self.text_shaping["paragraph_direction"] = paragraph.base_direction + + fragments = [] + for bidi_text, bidi_direction in directional_segments: + self.text_shaping["fragment_direction"] = bidi_direction + fragments += self._preload_font_styles(bidi_text, markdown) + return tuple(fragments) + + def _preload_font_styles(self, text, markdown): + """ + When Markdown styling is enabled, we require secondary fonts + to ender text in bold & italics. + This function ensure that those fonts are available. + It needs to perform Markdown parsing, + so we return the resulting `styled_txt_frags` tuple + to avoid repeating this processing later on. + """ + if not text: + return tuple() + prev_font_style = self.font_style + if self.underline: + prev_font_style += "U" + if self.strikethrough: + prev_font_style += "S" + styled_txt_frags = tuple(self._parse_chars(text, markdown)) + if markdown: + page = self.page + # We set the current to page to zero so that + # set_font() does not produce any text object on the stream buffer: + self.page = 0 + if any(frag.font_style == "B" for frag in styled_txt_frags): + # Ensuring bold font is supported: + self.set_font(style="B") + if any(frag.font_style == "I" for frag in styled_txt_frags): + # Ensuring italics font is supported: + self.set_font(style="I") + if any(frag.font_style == "BI" for frag in styled_txt_frags): + # Ensuring bold italics font is supported: + self.set_font(style="BI") + if any(frag.font_style == "" for frag in styled_txt_frags): + # Ensuring base font is supported: + self.set_font(style="") + for frag in styled_txt_frags: + frag.font = self.fonts[frag.font_family + frag.font_style] + # Restoring initial style: + self.set_font(style=prev_font_style) + self.page = page + return styled_txt_frags + + def _parse_chars(self, text: str, markdown: bool) -> Iterator[Fragment]: + "Split text into fragments" + if not markdown and not self.text_shaping and not self._fallback_font_ids: + if self.str_alias_nb_pages: + for seq, fragment_text in enumerate( + text.split(self.str_alias_nb_pages) + ): + if seq > 0: + yield TotalPagesSubstitutionFragment( + self.str_alias_nb_pages, + self._get_current_graphics_state(), + self.k, + ) + if fragment_text: + yield Fragment( + fragment_text, self._get_current_graphics_state(), self.k + ) + return + + yield Fragment(text, self._get_current_graphics_state(), self.k) + return + txt_frag, in_bold, in_italics, in_underline = ( + [], + "B" in self.font_style, + "I" in self.font_style, + bool(self.underline), + ) + current_fallback_font = None + current_text_script = None + + def frag(): + nonlocal txt_frag, current_fallback_font, current_text_script + gstate = self._get_current_graphics_state() + gstate["font_style"] = ("B" if in_bold else "") + ( + "I" if in_italics else "" + ) + gstate["underline"] = in_underline + if current_fallback_font: + gstate["font_family"] = "".join( + c for c in current_fallback_font if c.islower() + ) + gstate["font_style"] = "".join( + c for c in current_fallback_font if c.isupper() + ) + gstate["current_font"] = self.fonts[current_fallback_font] + current_fallback_font = None + current_text_script = None + fragment = Fragment( + txt_frag, + gstate, + self.k, + ) + txt_frag = [] + return fragment + + if self.is_ttf_font: + font_glyphs = self.current_font.cmap + else: + font_glyphs = [] + num_escape_chars = 0 + + while text: + is_marker = text[:2] in ( + self.MARKDOWN_BOLD_MARKER, + self.MARKDOWN_ITALICS_MARKER, + self.MARKDOWN_UNDERLINE_MARKER, + ) + half_marker = text[0] + text_script = get_unicode_script(text[0]) + if text_script not in ( + UnicodeScript.COMMON, + UnicodeScript.UNKNOWN, + current_text_script, + ): + if txt_frag and current_text_script: + yield frag() + current_text_script = text_script + + if self.str_alias_nb_pages: + if text[: len(self.str_alias_nb_pages)] == self.str_alias_nb_pages: + if txt_frag: + yield frag() + gstate = self._get_current_graphics_state() + gstate["font_style"] = ("B" if in_bold else "") + ( + "I" if in_italics else "" + ) + gstate["underline"] = in_underline + yield TotalPagesSubstitutionFragment( + self.str_alias_nb_pages, + gstate, + self.k, + ) + text = text[len(self.str_alias_nb_pages) :] + continue + + # Check that previous & next characters are not identical to the marker: + if markdown: + if ( + is_marker + and (not txt_frag or txt_frag[-1] != half_marker) + and (len(text) < 3 or text[2] != half_marker) + ): + txt_frag = ( + txt_frag[: -((num_escape_chars + 1) // 2)] + if num_escape_chars > 0 + else txt_frag + ) + if num_escape_chars % 2 == 0: + if txt_frag: + yield frag() + if text[:2] == self.MARKDOWN_BOLD_MARKER: + in_bold = not in_bold + if text[:2] == self.MARKDOWN_ITALICS_MARKER: + in_italics = not in_italics + if text[:2] == self.MARKDOWN_UNDERLINE_MARKER: + in_underline = not in_underline + text = text[2:] + continue + num_escape_chars = ( + num_escape_chars + 1 + if text[0] == self.MARKDOWN_ESCAPE_CHARACTER + else 0 + ) + is_link = self.MARKDOWN_LINK_REGEX.match(text) + if is_link: + link_text, link_dest, text = is_link.groups() + if txt_frag: + yield frag() + gstate = self._get_current_graphics_state() + gstate["underline"] = self.MARKDOWN_LINK_UNDERLINE + if self.MARKDOWN_LINK_COLOR: + gstate["text_color"] = self.MARKDOWN_LINK_COLOR + try: + page = int(link_dest) + link_dest = self.add_link(page=page) + except ValueError: + pass + yield Fragment( + list(link_text), + gstate, + self.k, + link=link_dest, + ) + continue + if self.is_ttf_font and text[0] != "\n" and not ord(text[0]) in font_glyphs: + style = ("B" if in_bold else "") + ("I" if in_italics else "") + fallback_font = self.get_fallback_font(text[0], style) + if fallback_font: + if fallback_font == current_fallback_font: + txt_frag.append(text[0]) + text = text[1:] + continue + if txt_frag: + yield frag() + current_fallback_font = fallback_font + txt_frag.append(text[0]) + text = text[1:] + continue + if current_fallback_font: + if txt_frag: + yield frag() + current_fallback_font = None + txt_frag.append(text[0]) + text = text[1:] + if txt_frag: + yield frag() + + def set_doc_option(self, opt, value): + """ + Defines a document option. + + Args: + opt (str): name of the option to set + value (str) option value + + .. deprecated:: 2.4.0 + Simply set the `FPDF.core_fonts_encoding` property as a replacement. + """ + warnings.warn( + ( + "set_doc_option() is deprecated since v2.4.0 " + "and will be removed in a future release. " + "Simply set the `.core_fonts_encoding` property as a replacement." + ), + DeprecationWarning, + stacklevel=get_stack_level(), + ) + if opt != "core_fonts_encoding": + raise FPDFException(f'Unknown document option "{opt}"') + self.core_fonts_encoding = value diff --git a/test/svg/generated_pdf/text-samples.pdf b/test/svg/generated_pdf/text-samples.pdf deleted file mode 100644 index 02ec1dedeebe012bef16b1c51b8405f9c39c2604..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1247 zcmah}T}TvB6b^zOMhQhR2*FD!C6Y6<2g1%@O)?2>RYk~D7b?^ST!wPvH=FXgR?svZLoHMtfOO2jl zn}ZY!7~otTqCz2xR-Ckr087#5&7=ns9CpR~D7phCO+DgdK!U-RfW$Qih5UxS-J>Z8 z=q`N#dQ?jb1??n(3;19=Q_v0@$aL&HCZMAxIt%QyWnlnvv|S%{(g@I}>>Xiy`hw(`| zzR0O(PN?gljTe_!uT<6vW1q%HHqtW@t>*QbxGg!`^*ujHi}M2V&E|L2UBt8UQ3k zJll2GF*Jxkg2sOamH{((9NUI*nXjs;a{i( zPY`icN^D|aFP;>{_dy{Jxw>gVH+zq0<_$=+(Q(H?fLsN*+HH2f14y~Zy9yE^Q3wkW sRgzUsWTH}llUP<{1c8gv6 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 Z)AvZAS3uyLOjxWD`22-NQyi@n{s)N2Tx|dV literal 20630 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 diff --git a/test/text/test_text_mode.py b/test/text/test_text_mode.py index d999231dc..8171b0e31 100644 --- a/test/text/test_text_mode.py +++ b/test/text/test_text_mode.py @@ -41,7 +41,7 @@ def test_clip_text_modes(tmp_path): with pdf.local_context(text_mode=TextMode.FILL_CLIP, text_color=(0, 255, 255)): pdf.cell(text="FILL_CLIP text mode") for r in range(0, 100, 1): - pdf.circle(x=110, y=22, radius=r) + pdf.circle(x=200, y=22, radius=r) pdf.ln() with pdf.local_context(text_mode=TextMode.STROKE_CLIP): pdf.cell(text="STROKE_CLIP text mode") @@ -53,7 +53,7 @@ def test_clip_text_modes(tmp_path): ): pdf.cell(text="FILL_STROKE_CLIP text mode") for r in range(0, 100, 1): - pdf.circle(x=110, y=78, radius=r) + pdf.circle(x=200, y=78, radius=r) pdf.ln() with pdf.local_context(text_mode=TextMode.CLIP): pdf.cell(text="CLIP text mode") diff --git a/tox.ini b/tox.ini index ea3a47548..f74f3fbd6 100644 --- a/tox.ini +++ b/tox.ini @@ -27,4 +27,4 @@ commands = deps = -rdocs/requirements.txt commands = mkdocs build - pdoc --html -o public/ fpdf + pdoc --html -o public/ fpdf --template-dir docs/pdoc