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/drawing.py b/fpdf/drawing.py
index 6bee8b7dc..9931bb15c 100644
--- a/fpdf/drawing.py
+++ b/fpdf/drawing.py
@@ -2800,14 +2800,14 @@ def __init__(self):
def add_item(
self,
item: Union["GraphicsContext", "PaintedPath", "PaintComposite"],
- _copy: bool = True,
+ clone: bool = True,
) -> None:
"""
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.
@@ -2816,7 +2816,7 @@ def add_item(
if not isinstance(item, (GraphicsContext, PaintedPath, PaintComposite)):
raise TypeError(f"{item} doesn't belong in a DrawingContext")
- if _copy:
+ if clone:
item = deepcopy(item)
self._subitems.append(item)
@@ -3097,24 +3097,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()
@@ -3144,7 +3144,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)
@@ -3179,7 +3179,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)
@@ -3223,7 +3223,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
@@ -3239,7 +3239,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):
@@ -3256,7 +3256,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):
@@ -3270,7 +3270,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):
@@ -3286,7 +3286,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):
@@ -3300,7 +3300,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):
@@ -3316,7 +3316,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):
@@ -3338,7 +3338,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):
@@ -3372,7 +3372,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):
@@ -3390,7 +3390,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):
@@ -3412,7 +3412,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):
@@ -3459,7 +3459,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
@@ -3508,7 +3508,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
@@ -3516,13 +3516,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
@@ -3744,19 +3744,19 @@ def clipping_path(self) -> Optional[ClippingPath]:
def clipping_path(self, new_clipath: ClippingPath) -> None:
self._clipping_path = new_clipath
- def add_item(self, item: Renderable, _copy: bool = True) -> None:
+ def add_item(self, item: Renderable, clone: bool = 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 = deepcopy(item)
self.path_items.append(item)
diff --git a/fpdf/font_type_3.py b/fpdf/font_type_3.py
index a8d70ff9d..880262a87 100644
--- a/fpdf/font_type_3.py
+++ b/fpdf/font_type_3.py
@@ -283,7 +283,7 @@ def draw_glyph_colrv0(self, layers):
glyph.draw(pen)
path.style.fill_color = self.get_color(layer.colorID)
path.style.stroke_color = self.get_color(layer.colorID)
- gc.add_item(item=path, _copy=False)
+ gc.add_item(item=path, clone=False)
return gc
def draw_glyph_colrv1(self, glyph_name):
@@ -318,7 +318,7 @@ def draw_colrv1_paint(
parent=group,
ctm=ctm,
)
- parent.add_item(item=group, _copy=False)
+ parent.add_item(item=group, clone=False)
return parent, target_path
if paint.Format in (
@@ -403,8 +403,8 @@ def draw_colrv1_paint(
ctm=Transform.identity(),
)
if surface_path is not None:
- group.add_item(item=surface_path, _copy=False)
- parent.add_item(item=group, _copy=False)
+ group.add_item(item=surface_path, clone=False)
+ parent.add_item(item=group, clone=False)
return parent, None
if paint.Format == PaintFormat.PaintColrGlyph:
@@ -419,7 +419,7 @@ def draw_colrv1_paint(
group = GraphicsContext()
self.draw_colrv1_paint(paint=rec.Paint, parent=group, ctm=ctm)
- parent.add_item(item=group, _copy=False)
+ parent.add_item(item=group, clone=False)
return parent, target_path
if paint.Format in (
@@ -465,7 +465,7 @@ def draw_colrv1_paint(
ctm=ctm,
)
if backdrop_path is not None:
- backdrop_node.add_item(item=backdrop_path, _copy=False)
+ backdrop_node.add_item(item=backdrop_path, clone=False)
source_node = GraphicsContext()
_, source_path = self.draw_colrv1_paint(
@@ -474,20 +474,20 @@ def draw_colrv1_paint(
ctm=ctm,
)
if source_path is not None:
- source_node.add_item(item=source_path, _copy=False)
+ source_node.add_item(item=source_path, clone=False)
composite_type, composite_mode = self.get_composite_mode(
paint.CompositeMode
)
if composite_type == "Blend":
source_node.style.blend_mode = composite_mode
- parent.add_item(item=backdrop_node, _copy=False)
- parent.add_item(item=source_node, _copy=False)
+ parent.add_item(item=backdrop_node, clone=False)
+ parent.add_item(item=source_node, clone=False)
elif composite_type == "Compositing":
composite_node = PaintComposite(
backdrop=backdrop_node, source=source_node, operation=composite_mode
)
- parent.add_item(item=composite_node, _copy=False)
+ parent.add_item(item=composite_node, clone=False)
else:
raise ValueError(""" Composite operation not supported """)
return parent, None
diff --git a/fpdf/fonts.py b/fpdf/fonts.py
index ed634d9bb..d703897f4 100644
--- a/fpdf/fonts.py
+++ b/fpdf/fonts.py
@@ -224,8 +224,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 2fe0db878..415e655fc 100644
--- a/fpdf/fpdf.py
+++ b/fpdf/fpdf.py
@@ -21,16 +21,13 @@
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 (
Any,
Callable,
ContextManager,
Dict,
- Iterator,
NamedTuple,
Optional,
Union,
@@ -61,7 +58,6 @@ class Image:
PDFAnnotation,
PDFEmbeddedFile,
)
-from .bidi import BidiParagraph, auto_detect_base_direction
from .deprecation import (
WarnOnDeprecatedModuleAttributes,
get_stack_level,
@@ -93,16 +89,14 @@ class Image:
PathPaintRule,
PDFResourceType,
RenderStyle,
- TextDirection,
- TextEmphasis,
TextMarkupType,
TextMode,
WrapMode,
XPos,
YPos,
)
-from .errors import FPDFException, FPDFPageFormatException, FPDFUnicodeEncodingException
-from .fonts import CORE_FONTS, CoreFont, 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 (
@@ -118,7 +112,6 @@ class Image:
preload_image,
)
from .line_break import (
- Fragment,
MultiLineBreak,
TextLine,
TotalPagesSubstitutionFragment,
@@ -141,9 +134,10 @@ class Image:
from .svg import Percent, SVGObject
from .syntax import DestinationXYZ, PDFArray, PDFDate
from .table import Table, draw_box_borders
+from .text_renderer import TextRendererMixin
from .text_region import TextColumns, TextRegionMixin
from .transitions import Transition
-from .unicode_script import UnicodeScript, get_unicode_script
+from .unicode_script import get_unicode_script
from .util import Padding, get_scale_factor
# Public global variables:
@@ -159,8 +153,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,
@@ -228,7 +220,7 @@ def wrapper(self, *args, **kwargs):
return wrapper
-class FPDF(GraphicsStateMixin, TextRegionMixin):
+class FPDF(GraphicsStateMixin, TextRendererMixin, TextRegionMixin):
"PDF Generation class"
MARKDOWN_BOLD_MARKER = "**"
@@ -286,8 +278,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()
@@ -322,32 +312,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.current_font_is_set_on_page = False # current font and size are already added to current page contents with _out
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)
@@ -373,12 +342,8 @@ 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.render_color_fonts = True
self._current_draw_context = None
- # map page numbers to a set of GraphicsState names:
self._record_text_quad_points = False
self._resource_catalog = ResourceCatalog()
@@ -454,20 +419,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
@@ -672,85 +623,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 containing 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
@@ -866,30 +738,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:
@@ -1020,8 +868,6 @@ def add_page(
"A page cannot be added on a closed document, after calling output()"
)
- self.current_font_is_set_on_page = False
-
family = self.font_family
emphasis = self.emphasis
size = self.font_size_pt
@@ -1155,9 +1001,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(
@@ -1246,41 +1090,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).
@@ -1362,8 +1171,6 @@ def drawing_context(self, debug_stream=None):
# Let the catalog scan & register resources used by this drawing:
self._resource_catalog.index_stream_resources(rendered, 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:
@@ -2155,247 +1962,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(self, fontkey, style)
-
- # Select it
- self.font_family = family
- self.font_style = style
- self.font_size_pt = size
- self.current_font = self.fonts[fontkey]
- self.current_font_is_set_on_page = False
-
- 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
- self.current_font_is_set_on_page = False
-
- def _set_font_for_page(self, font, font_size_pt, wrap_in_text_object=True):
- """
- Set font and size for current page.
- This step is needed before adding text into page and not needed in set_font and set_font_size.
- """
- sl = f"/F{font.i} {font_size_pt:.2f} Tf"
- if wrap_in_text_object:
- sl = f"BT {sl} ET"
- self._resource_catalog.add(PDFResourceType.FONT, font.i, self.page)
- self.current_font_is_set_on_page = True
- return sl
-
- 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.
@@ -3329,6 +2895,8 @@ def cell(
prevent_font_change=markdown,
)
+ # 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,
@@ -3690,264 +3258,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 overridden 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_strikethrough, in_underline = (
- [],
- "B" in self.font_style,
- "I" in self.font_style,
- bool(self.strikethrough),
- 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["strikethrough"] = in_strikethrough
- gstate["underline"] = in_underline
- if current_fallback_font:
- style = "".join(c for c in current_fallback_font if c in ("BI"))
- family = current_fallback_font.replace("B", "").replace("I", "")
- gstate["font_family"] = family
- gstate["font_style"] = style
- 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_STRIKETHROUGH_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["strikethrough"] = in_strikethrough
- 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_STRIKETHROUGH_MARKER:
- in_strikethrough = not in_strikethrough
- 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,
@@ -4588,7 +3898,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
@@ -4638,7 +3950,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:
@@ -4659,7 +3971,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)
@@ -4735,7 +4047,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
@@ -4855,7 +4167,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
+ )
def preload_glyph_image(self, glyph_image_bytes):
return preload_image(
@@ -4957,21 +4271,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,
@@ -5588,69 +4887,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,
- )
- self.current_font_is_set_on_page = False
- 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: Any, **kwargs: Any) -> ContextManager[Table]:
diff --git a/fpdf/image_parsing.py b/fpdf/image_parsing.py
index 7541c97ab..5b4dce19a 100644
--- a/fpdf/image_parsing.py
+++ b/fpdf/image_parsing.py
@@ -28,6 +28,7 @@
from .errors import FPDFException
from .image_datastructures import ImageCache, RasterImageInfo, VectorImageInfo
from .svg import SVGObject
+from .text_renderer import TextRendererMixin
@dataclass
@@ -77,7 +78,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.
@@ -99,13 +102,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):
@@ -203,8 +212,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 f2836fdfb..bc0a138a9 100644
--- a/fpdf/svg.py
+++ b/fpdf/svg.py
@@ -14,8 +14,6 @@
from fontTools.svgLib.path import parse_path
-from .enums import PathPaintRule
-
try:
from defusedxml.ElementTree import fromstring as parse_xml_str
except ImportError:
@@ -35,8 +33,10 @@
PathPen,
ClippingPath,
)
+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__)
@@ -582,7 +582,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 = {}
@@ -772,6 +778,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)
@@ -805,6 +812,10 @@ def handle_defs(self, defs):
self.build_path(child)
elif child.tag in xmlns_lookup("svg", "image"):
self.build_image(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"):
@@ -870,22 +881,26 @@ 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", "a"):
# tags aren't supported but we need to recurse into them to
# render nested elements.
LOGGER.warning(
"Ignoring unsupported SVG tag: (contributions are welcome to add support for it)",
)
- 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)
+ # 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)",
@@ -944,6 +959,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!)'
+ )
+ 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
+
@force_nodocument
def build_image(self, image):
href = None
@@ -980,6 +1032,47 @@ 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):
+ 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 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():
+ 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
@@ -1011,7 +1104,7 @@ def render(self, _resource_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 9e25bad6d..3dcc796d6 100644
--- a/fpdf/text_region.py
+++ b/fpdf/text_region.py
@@ -253,7 +253,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..73895a43d
--- /dev/null
+++ b/fpdf/text_renderer.py
@@ -0,0 +1,791 @@
+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_primitives 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.current_font_is_set_on_page = False
+ self.text_color = self.DEFAULT_TEXT_COLOR
+ self.render_color_fonts = True
+ 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
+ self.current_font_is_set_on_page = False
+
+ 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,
+ )
+ self.current_font_is_set_on_page = False
+ 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]
+ self.current_font_is_set_on_page = False
+
+ 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 containing 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 overridden 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_strikethrough, in_underline = (
+ [],
+ "B" in self.font_style,
+ "I" in self.font_style,
+ bool(self.strikethrough),
+ 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["strikethrough"] = in_strikethrough
+ gstate["underline"] = in_underline
+ if current_fallback_font:
+ style = "".join(c for c in current_fallback_font if c in ("BI"))
+ family = current_fallback_font.replace("B", "").replace("I", "")
+ gstate["font_family"] = family
+ gstate["font_style"] = style
+ 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_STRIKETHROUGH_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["strikethrough"] = in_strikethrough
+ 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_STRIKETHROUGH_MARKER:
+ in_strikethrough = not in_strikethrough
+ 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
+
+ def _set_font_for_page(self, font, font_size_pt, wrap_in_text_object=True):
+ """
+ Set font and size for current page.
+ This step is needed before adding text into page and not needed in set_font and set_font_size.
+ """
+ sl = f"/F{font.i} {font_size_pt:.2f} Tf"
+ if wrap_in_text_object:
+ sl = f"BT {sl} ET"
+ self._resource_catalog.add(PDFResourceType.FONT, font.i, self.page)
+ self.current_font_is_set_on_page = True
+ return sl
diff --git a/test/outline/toc_no_reset_page_indices.pdf b/test/outline/toc_no_reset_page_indices.pdf
index f8074bf4e..2a88e833f 100644
Binary files a/test/outline/toc_no_reset_page_indices.pdf and b/test/outline/toc_no_reset_page_indices.pdf differ
diff --git a/test/outline/toc_with_extra_page_0.pdf b/test/outline/toc_with_extra_page_0.pdf
index 1e74d7312..a43fd7e3d 100644
Binary files a/test/outline/toc_with_extra_page_0.pdf and b/test/outline/toc_with_extra_page_0.pdf differ
diff --git a/test/outline/toc_with_extra_page_1.pdf b/test/outline/toc_with_extra_page_1.pdf
index 4b5060a98..0c776020c 100644
Binary files a/test/outline/toc_with_extra_page_1.pdf and b/test/outline/toc_with_extra_page_1.pdf differ
diff --git a/test/outline/toc_with_extra_page_2.pdf b/test/outline/toc_with_extra_page_2.pdf
index 0aa733aad..430855ecb 100644
Binary files a/test/outline/toc_with_extra_page_2.pdf and b/test/outline/toc_with_extra_page_2.pdf differ
diff --git a/test/svg/parameters.py b/test/svg/parameters.py
index 476257b05..1d056e7ec 100644
--- a/test/svg/parameters.py
+++ b/test/svg/parameters.py
@@ -783,6 +783,9 @@ def Gs(**kwargs):
svgfile("path_clippingpath.svg"),
id=" containing a used in a group with color - issue #1147",
),
+ # pylint: disable=fixme
+ # TODO: enable this test
+ # 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 @@