diff --git a/fpdf/drawing.py b/fpdf/drawing.py index 29ee6cd06..bd76c0b8f 100644 --- a/fpdf/drawing.py +++ b/fpdf/drawing.py @@ -13,6 +13,8 @@ from contextlib import contextmanager from typing import Optional, NamedTuple, Union +from fontTools.pens.basePen import BasePen + from .enums import ( BlendMode, ClippingPathIntersectionRule, @@ -3787,6 +3789,11 @@ def _insert_implicit_close_if_open(self): self._close_context = self._graphics_context self._closed = True + def linear_gradient_fill(self, x1, y1, x2, y2, stops): + gradient = LinearGradient(fpdf=None, from_x=x1, from_y=y1, to_x=x2, to_ys=y2, colors=[stop[1] for stop in stops]) + gradient.coords = list(map(str, [x1, x2, y1, y2])) + self.gradient=gradient + def render( self, gsd_registry, style, last_item, initial_point, debug_stream=None, pfx=None ): @@ -4220,3 +4227,58 @@ def render_debug( pfx, _push_stack=_push_stack, ) + + +class PathPen(BasePen): + def __init__(self, pdf_path, *args, **kwargs): + self.pdf_path = pdf_path + self.last_was_line_to = False + self.first_is_move = None + super().__init__(*args, **kwargs) + + def _moveTo(self, pt): + self.pdf_path.move_to(*pt) + self.last_was_line_to = False + if self.first_is_move is None: + self.first_is_move = True + + def _lineTo(self, pt): + self.pdf_path.line_to(*pt) + self.last_was_line_to = True + if self.first_is_move is None: + self.first_is_move = False + + def _curveToOne(self, pt1, pt2, pt3): + self.pdf_path.curve_to( + x1=pt1[0], y1=pt1[1], x2=pt2[0], y2=pt2[1], x3=pt3[0], y3=pt3[1] + ) + self.last_was_line_to = False + if self.first_is_move is None: + self.first_is_move = False + + def _qCurveToOne(self, pt1, pt2): + self.pdf_path.quadratic_curve_to(x1=pt1[0], y1=pt1[1], x2=pt2[0], y2=pt2[1]) + self.last_was_line_to = False + if self.first_is_move is None: + self.first_is_move = False + + def arcTo(self, rx, ry, rotation, arc, sweep, end): + self.pdf_path.arc_to( + rx=rx, + ry=ry, + rotation=rotation, + large_arc=arc, + positive_sweep=sweep, + x=end[0], + y=end[1], + ) + self.last_was_line_to = False + if self.first_is_move is None: + self.first_is_move = False + + def _closePath(self): + # The fonttools parser inserts an unnecessary explicit line back to the start + # point of the path before actually closing it. Let's get rid of that again. + # if self.last_was_line_to: + # self.pdf_path.remove_last_path_element() + self.pdf_path.close() diff --git a/fpdf/font_type_3.py b/fpdf/font_type_3.py new file mode 100644 index 000000000..81066d02e --- /dev/null +++ b/fpdf/font_type_3.py @@ -0,0 +1,370 @@ +import logging + +from typing import List, Tuple, TYPE_CHECKING +from io import BytesIO +from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics +from fontTools.ttLib.tables.C_O_L_R_ import table_C_O_L_R_ +from fontTools.ttLib.tables.otTables import Paint, PaintFormat + +from .drawing import DeviceRGB, GraphicsContext, Transform, PathPen, PaintedPath + + +if TYPE_CHECKING: + from .fpdf import FPDF + from .fonts import TTFFont + + +LOGGER = logging.getLogger(__name__) + + +class Type3FontGlyph: + # RAM usage optimization: + __slots__ = ( + "obj_id", + "glyph_id", + "unicode", + "glyph_name", + "glyph_width", + "glyph", + "_glyph_bounds", + ) + obj_id: int + glyph_id: int + unicode: Tuple + glyph_name: str + glyph_width: int + glyph: str + _glyph_bounds: Tuple[int, int, int, int] + + def __init__(self): + pass + + def __hash__(self): + return self.glyph_id + + +class Type3Font: + + def __init__(self, fpdf: "FPDF", base_font: "TTFFont"): + self.i = 1 + self.type = "type3" + self.fpdf = fpdf + self.base_font = base_font + self.upem = self.base_font.ttfont["head"].unitsPerEm + self.scale = 1000 / self.upem + self.images_used = set() + self.graphics_style_used = set() + self.glyphs: List[Type3FontGlyph] = [] + + def get_notdef_glyph(self, glyph_id) -> Type3FontGlyph: + notdef = Type3FontGlyph() + notdef.glyph_id = glyph_id + notdef.unicode = glyph_id + notdef.glyph_name = ".notdef" + notdef.glyph_width = self.base_font.cw[0x00] if self.base_font.cw[0x00] else 0 + notdef.glyph = f"{notdef.glyph_width} 0 d0" + return notdef + + def get_space_glyph(self, glyph_id) -> Type3FontGlyph: + space = Type3FontGlyph() + space.glyph_id = glyph_id + space.unicode = 0x20 + space.glyph_name = "space" + space_width = ( + self.base_font.cw[0x20] + if self.base_font.cw[0x20] + else self.base_font.ttfont["hmtx"].metrics[".notdef"][0] + ) + space.glyph_width = space_width + space.glyph = f"{space.glyph_width} 0 d0" + return space + + def load_glyphs(self): + for glyph, char_id in self.base_font.subset.items(): + if not self.glyph_exists(glyph.glyph_name): + if char_id == 0x20 or glyph.glyph_name == "space": + print("is space") + self.glyphs.append(self.get_space_glyph(char_id)) + continue + if self.glyph_exists(".notdef"): + self.add_glyph(".notdef", char_id) + continue + self.glyphs.append(self.get_notdef_glyph(char_id)) + continue + self.add_glyph(glyph.glyph_name, char_id) + + def add_glyph(self, glyph_name, char_id): + g = Type3FontGlyph() + g.glyph_id = char_id + g.unicode = char_id + g.glyph_name = glyph_name + self.load_glyph_image(g) + self.glyphs.append(g) + + @classmethod + def get_target_ppem(cls, font_size_pt: int) -> int: + # Calculating the target ppem: + # https://learn.microsoft.com/en-us/typography/opentype/spec/ttch01#display-device-characteristics + # ppem = point_size * dpi / 72 + # The default PDF dpi resolution is 72 dpi - and we have the 72 dpi hardcoded on our scale factor, + # so we can simplify the calculation. + return font_size_pt + + def load_glyph_image(self, glyph: Type3FontGlyph): + raise NotImplementedError("Method must be implemented on child class") + + def glyph_exists(self, glyph_name: str) -> bool: + raise NotImplementedError("Method must be implemented on child class") + + +class SVGColorFont(Type3Font): + + def glyph_exists(self, glyph_name): + glyph_id = self.base_font.ttfont.getGlyphID(glyph_name) + return any( + svg_doc.startGlyphID <= glyph_id <= svg_doc.endGlyphID + for svg_doc in self.base_font.ttfont["SVG "].docList + ) + + def load_glyph_image(self, glyph: Type3FontGlyph): + glyph_id = self.base_font.ttfont.getGlyphID(glyph.glyph_name) + glyph_svg_data = None + for svg_doc in self.base_font.ttfont["SVG "].docList: + if svg_doc.startGlyphID <= glyph_id <= svg_doc.endGlyphID: + glyph_svg_data = svg_doc.data.encode("utf-8") + break + bio = BytesIO(glyph_svg_data) + bio.seek(0) + _, img, _ = self.fpdf.preload_image(bio, None) + w = round(self.base_font.ttfont["hmtx"].metrics[glyph.glyph_name][0] + 0.001) + # img.base_group.transform = Transform.identity() + img.base_group.transform = Transform.scaling(self.scale, self.scale) + output_stream = self.fpdf.draw_vector_glyph(img.base_group, self) + glyph.glyph = ( + f"{w * self.scale / self.upem} 0 d0\n" "q\n" f"{output_stream}\n" "Q" + ) + glyph.glyph_width = w + + +class COLRFont(Type3Font): + + def __init__(self, fpdf: "FPDF", base_font: "TTFFont"): + super().__init__(fpdf, base_font) + colr_table: table_C_O_L_R_ = self.base_font.ttfont["COLR"] + self.colrv0_glyphs = [] + self.colrv1_glyphs = [] + self.version = colr_table.version + if colr_table.version == 0: + self.colrv0_glyphs = colr_table.ColorLayers + else: + self.colrv0_glyphs = colr_table._decompileColorLayersV0(colr_table.table) + self.colrv1_glyphs = { + glyph.BaseGlyph: glyph + for glyph in colr_table.table.BaseGlyphList.BaseGlyphPaintRecord + } + self.palette = None + if "CPAL" in self.base_font.ttfont: + # hardcoding the first palette for now + print(f"This font has {len(self.base_font.ttfont['CPAL'].palettes)} palettes") + palette = self.base_font.ttfont["CPAL"].palettes[0] + self.palette = [ + (color.red / 255, color.green / 255, color.blue / 255, color.alpha / 255) for color in palette + ] + + + def glyph_exists(self, glyph_name): + return glyph_name in self.colrv0_glyphs or glyph_name in self.colrv1_glyphs + + def load_glyph_image(self, glyph: Type3FontGlyph): + w = round(self.base_font.ttfont["hmtx"].metrics[glyph.glyph_name][0] + 0.001) + if glyph.glyph_name in self.colrv0_glyphs: + glyph_layers = self.base_font.ttfont["COLR"].ColorLayers[glyph.glyph_name] + img = self.draw_glyph_colrv0(glyph_layers) + else: + img = self.draw_glyph_colrv1(glyph.glyph_name) + img.transform = img.transform @ Transform.scaling(self.scale, -self.scale) + output_stream = self.fpdf.draw_vector_glyph(img, self) + glyph.glyph = ( + f"{w * self.scale / self.upem} 0 d0\n" "q\n" f"{output_stream}\n" "Q" + ) + glyph.glyph_width = w + + def get_color(self, color_index, alpha=1): + r, g, b, a = self.palette[color_index] + a *= alpha + return DeviceRGB(r, g, b, a) + + def draw_glyph_colrv0(self, layers): + gc = GraphicsContext() + gc.transform = Transform.identity() + for layer in layers: + path = PaintedPath() + glyph_set = self.base_font.ttfont.getGlyphSet() + pen = PathPen(path, glyphSet=glyph_set) + glyph = glyph_set[layer.name] + glyph.draw(pen) + path.style.fill_color = self.get_color(layer.colorID) + path.style.stroke_color = self.get_color(layer.colorID) + gc.add_item(path) + return gc + + def draw_glyph_colrv1(self, glyph_name): + gc = GraphicsContext() + gc.transform = Transform.identity() + glyph = self.colrv1_glyphs[glyph_name] + self.draw_colrv1_paint(glyph.Paint, gc) + return gc + + def draw_colrv1_paint(self, paint: Paint, gc: GraphicsContext): + print(paint.getFormatName()) + if paint.Format == PaintFormat.PaintColrLayers: #1 + print("[PaintColrLayers] FirstLayerIndex: ", paint.FirstLayerIndex, " NumLayers: ", paint.NumLayers) + layer_list = self.base_font.ttfont["COLR"].table.LayerList + for layer in range(paint.FirstLayerIndex, paint.FirstLayerIndex + paint.NumLayers): + self.draw_colrv1_paint(layer_list.Paint[layer], gc) + elif paint.Format == PaintFormat.PaintSolid: #2 + color = self.get_color(paint.PaletteIndex, paint.Alpha) + path: PaintedPath = gc.path_items[-1] + path.style.fill_color = color + path.style.stroke_color = color + elif paint.Format == PaintFormat.PaintLinearGradient: #4 + print("[PaintLinearGradient] ColorLine: ") + for stop in paint.ColorLine.ColorStop: + print("Stop: ", stop.StopOffset, " color: ", stop.PaletteIndex) + print("x0: ", paint.x0, " y0: ", paint.y0, " x1: ", paint.x1, " y1: ", paint.y1) + elif paint.Format == PaintFormat.PaintGlyph: #10 + path = PaintedPath() + glyph_set = self.base_font.ttfont.getGlyphSet() + pen = PathPen(path, glyphSet=glyph_set) + glyph = glyph_set[paint.Glyph] + glyph.draw(pen) + gc.add_item(path) + self.draw_colrv1_paint(paint.Paint, gc) + else: + print("Unknown PaintFormat: ", paint.Format) + + + + +class CBDTColorFont(Type3Font): + + # Only looking at the first strike - Need to look all strikes available on the CBLC table first? + + def glyph_exists(self, glyph_name): + return glyph_name in self.base_font.ttfont["CBDT"].strikeData[0] + + def load_glyph_image(self, glyph: Type3FontGlyph): + ppem = self.base_font.ttfont["CBLC"].strikes[0].bitmapSizeTable.ppemX + g = self.base_font.ttfont["CBDT"].strikeData[0][glyph.glyph_name] + glyph_bitmap = g.data[9:] + metrics = g.metrics + if isinstance(metrics, SmallGlyphMetrics): + x_min = round(metrics.BearingX * self.upem / ppem) + y_min = round((metrics.BearingY - metrics.height) * self.upem / ppem) + x_max = round(metrics.width * self.upem / ppem) + y_max = round(metrics.BearingY * self.upem / ppem) + elif isinstance(metrics, BigGlyphMetrics): + x_min = round(metrics.horiBearingX * self.upem / ppem) + y_min = round((metrics.horiBearingY - metrics.height) * self.upem / ppem) + x_max = round(metrics.width * self.upem / ppem) + y_max = round(metrics.horiBearingY * self.upem / ppem) + else: # fallback scenario: use font bounding box + x_min = self.base_font.ttfont["head"].xMin + y_min = self.base_font.ttfont["head"].yNin + x_max = self.base_font.ttfont["head"].xMax + y_max = self.base_font.ttfont["head"].yMax + + bio = BytesIO(glyph_bitmap) + bio.seek(0) + _, _, info = self.fpdf.preload_image(bio, None) + w = round(self.base_font.ttfont["hmtx"].metrics[glyph.glyph_name][0] + 0.001) + glyph.glyph = ( + f"{w / self.scale} 0 d0\n" + "q\n" + f"{(x_max - x_min)* self.scale} 0 0 {(-y_min + y_max)*self.scale} {x_min*self.scale} {y_min*self.scale} cm\n" + f"/I{info['i']} Do\nQ" + ) + self.images_used.add(info["i"]) + glyph.glyph_width = w + + +class SBIXColorFont(Type3Font): + + def glyph_exists(self, glyph_name): + glyph = ( + self.base_font.ttfont["sbix"] + .strikes[self.get_strike_index()] + .glyphs.get(glyph_name) + ) + return glyph and glyph.graphicType + + def get_strike_index(self): + target_ppem = self.get_target_ppem(self.base_font.biggest_size_pt) + ppem_list = [ + ppem + for ppem in self.base_font.ttfont["sbix"].strikes.keys() + if ppem >= target_ppem + ] + if not ppem_list: + return max(list(self.base_font.ttfont["sbix"].strikes.keys())) + return min(ppem_list) + + def load_glyph_image(self, glyph: Type3FontGlyph): + ppem = self.get_strike_index() + sbix_glyph = ( + self.base_font.ttfont["sbix"].strikes[ppem].glyphs.get(glyph.glyph_name) + ) + if sbix_glyph.graphicType == "dupe": + raise NotImplementedError( + f"{glyph.glyph_name}: Dupe SBIX graphic type not implemented." + ) + # waiting for an example to test + # dupe_char = font.getBestCmap()[glyph.imageData] + # return self.get_color_glyph(dupe_char) + + if sbix_glyph.graphicType not in ("jpg ", "png ", "tiff"): # pdf or mask + raise NotImplementedError( + f" {glyph.glyph_name}: Invalid SBIX graphic type {sbix_glyph.graphicType}." + ) + + bio = BytesIO(sbix_glyph.imageData) + bio.seek(0) + _, _, info = self.fpdf.preload_image(bio, None) + w = round(self.base_font.ttfont["hmtx"].metrics[glyph.glyph_name][0] + 0.001) + glyf_metrics = self.base_font.ttfont["glyf"].get(glyph.glyph_name) + x_min = glyf_metrics.xMin + sbix_glyph.originOffsetX + x_max = glyf_metrics.xMax + sbix_glyph.originOffsetX + y_min = glyf_metrics.yMin + sbix_glyph.originOffsetY + y_max = glyf_metrics.yMax + sbix_glyph.originOffsetY + + glyph.glyph = ( + f"{(x_max - x_min) * self.scale} 0 d0\n" + "q\n" + f"{(x_max - x_min) * self.scale} 0 0 {(-y_min + y_max) * self.scale} {x_min * self.scale} {y_min * self.scale} cm\n" + f"/I{info['i']} Do\nQ" + ) + self.images_used.add(info["i"]) + glyph.glyph_width = w + + +# pylint: disable=too-many-return-statements +def get_color_font_object(fpdf: "FPDF", base_font: "TTFFont") -> Type3Font: + if "CBDT" in base_font.ttfont: + LOGGER.warning("Font %s is a CBLC+CBDT color font", base_font.name) + return CBDTColorFont(fpdf, base_font) + if "EBDT" in base_font.ttfont: + LOGGER.warning("%s - EBLC+EBDT color font is not supported yet", base_font.name) + return None + if "COLR" in base_font.ttfont: + if base_font.ttfont["COLR"].version == 0: + LOGGER.warning("Font %s is a COLRv0 color font", base_font.name) + else: + LOGGER.warning("Font %s is a COLRv1 color font", base_font.name) + return COLRFont(fpdf, base_font) + if "SVG " in base_font.ttfont: + LOGGER.warning("Font %s is a SVG color font", base_font.name) + return SVGColorFont(fpdf, base_font) + if "sbix" in base_font.ttfont: + LOGGER.warning("Font %s is a SBIX color font", base_font.name) + return SBIXColorFont(fpdf, base_font) + return None diff --git a/fpdf/fonts.py b/fpdf/fonts.py index ecc501f13..8ecd28e29 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -35,6 +35,7 @@ def __deepcopy__(self, _memo): from .drawing import convert_to_device_color, DeviceGray, DeviceRGB from .enums import FontDescriptorFlags, TextEmphasis, Align from .syntax import Name, PDFObject +from .font_type_3 import get_color_font_object from .util import escape_parens @@ -238,7 +239,7 @@ class TTFFont: "name", "desc", "glyph_ids", - "hbfont", + "_hbfont", "sp", "ss", "up", @@ -252,12 +253,15 @@ class TTFFont: "cmap", "ttfont", "missing_glyphs", + "biggest_size_pt", + "color_font", ) def __init__(self, fpdf, font_file_path, fontkey, style): self.i = len(fpdf.fonts) + 1 self.type = "TTF" self.ttffile = font_file_path + self._hbfont = None self.fontkey = fontkey # recalcTimestamp=False means that it doesn't modify the "modified" timestamp in head table @@ -344,15 +348,32 @@ def __init__(self, fpdf, font_file_path, fontkey, style): self.ss = round(os2_table.yStrikeoutSize * self.scale) self.emphasis = TextEmphasis.coerce(style) self.subset = SubsetMap(self, [ord(char) for char in sbarr]) + self.biggest_size_pt = 0 + self.color_font = get_color_font_object(fpdf, self) + + # pylint: disable=no-member + @property + def hbfont(self): + if not self._hbfont: + self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile))) + return self._hbfont def __repr__(self): return f"TTFFont(i={self.i}, fontkey={self.fontkey})" def close(self): self.ttfont.close() - self.hbfont = None + self._hbfont = None + + def escape_text(self, text): + if self.color_font: + encoded = text.encode("latin-1", errors="replace") + return escape_parens(encoded.decode("latin-1", errors="ignore")) + return escape_parens(text.encode("utf-16-be").decode("latin-1")) def get_text_width(self, text, font_size_pt, text_shaping_parms): + if font_size_pt > self.biggest_size_pt: + self.biggest_size_pt = font_size_pt if text_shaping_parms: return self.shaped_text_width(text, font_size_pt, text_shaping_parms) return (len(text), sum(self.cw[ord(c)] for c in text) * font_size_pt * 0.001) @@ -384,8 +405,6 @@ def perform_harfbuzz_shaping(self, text, font_size_pt, text_shaping_parms): """ This method invokes Harfbuzz to perform text shaping of the input string """ - if not hasattr(self, "hbfont"): - self.hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile))) self.hbfont.ptem = font_size_pt buf = hb.Buffer() buf.cluster_level = 1 @@ -408,7 +427,7 @@ def encode_text(self, text): # Instead of adding the actual character to the stream its code is # mapped to a position in the font's subset txt_mapped += chr(self.subset.pick(uni)) - return f'({escape_parens(txt_mapped.encode("utf-16-be").decode("latin-1"))}) Tj' + return f"({self.escape_text(txt_mapped)}) Tj" def shape_text(self, text, font_size_pt, text_shaping_parms): """ @@ -603,6 +622,9 @@ def get_glyph( if unicode == 0x00: glyph_id = next(iter(self.font.cmap)) return Glyph(glyph_id, (0x00,), ".notdef", 0) + if unicode == 0x20: + glyph_id = next(iter(self.font.cmap)) + return Glyph(glyph_id, (0x20,), "space", self.font.cw[0x20]) return None def get_all_glyph_names(self): diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 6c5720960..82b2e6017 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -1382,6 +1382,60 @@ def _write_dash_pattern(self, dash, gap, phase): dstr = "[] 0 d" self._out(dstr) + @contextmanager + def glyph_drawing_context(self): + """ + Create a context for drawing paths for type 3 font glyphs, without writing on the current page. + """ + + if self._current_draw_context is not None: + raise FPDFException( + "cannot create a drawing context while one is already open" + ) + + context = DrawingContext() + self._current_draw_context = context + try: + yield context + finally: + self._current_draw_context = None + + self._set_min_pdf_version("1.4") + + def draw_vector_glyph(self, path, font): + """ + Add a pre-constructed path to the document. + + Args: + path (drawing.PaintedPath): the path to be drawn. + debug_stream (TextIO): print a pretty tree of all items to be rendered + to the provided stream. To store the output in a string, use + `io.StringIO`. + """ + output_stream = None + with self.glyph_drawing_context() as ctxt: + ctxt.add_item(path) + + starting_style = GraphicsStyle() + render_args = ( + self._drawing_graphics_state_registry, + Point(0, 0), + 1, + 0, + starting_style, + ) + + output_stream = ctxt.render(*render_args) + # Registering raster images embedded in the vector graphics: + font.images_used.update( + int(match.group(1)) for match in self._IMG_REGEX.finditer(output_stream) + ) + font.graphics_style_used.update( + match.group(1) for match in self._GS_REGEX.finditer(output_stream) + ) + + return output_stream + @check_page def line(self, x1, y1, x2, y2): """ @@ -5474,6 +5528,9 @@ def output( str(self.pages_count) ).encode("latin-1"), ) + for _, font in self.fonts.items(): + if font.type == "TTF" and font.color_font: + font.color_font.load_glyphs() if linearize: output_producer_class = LinearizedOutputProducer output_producer = output_producer_class(self) diff --git a/fpdf/line_break.py b/fpdf/line_break.py index b7857985b..d23757c9b 100644 --- a/fpdf/line_break.py +++ b/fpdf/line_break.py @@ -274,7 +274,7 @@ def render_pdf_text_ttf(self, frag_ws, word_spacing): mapped_text += chr(mapped_char) if word_spacing: # do this once in advance - u_space = escape_parens(" ".encode("utf-16-be").decode("latin-1")) + u_space = self.font.escape_text(" ") # According to the PDF reference, word spacing shall be applied to every # occurrence of the single-byte character code 32 in a string when using @@ -289,7 +289,7 @@ def render_pdf_text_ttf(self, frag_ws, word_spacing): words_strl = [] for word_i, word in enumerate(words): # pylint: disable=redefined-loop-name - word = escape_parens(word.encode("utf-16-be").decode("latin-1")) + word = self.font.escape_text(word) if word_i == 0: words_strl.append(f"({word})") else: @@ -298,9 +298,7 @@ def render_pdf_text_ttf(self, frag_ws, word_spacing): escaped_text = " ".join(words_strl) ret += f"[{escaped_text}] TJ" else: - escaped_text = escape_parens( - mapped_text.encode("utf-16-be").decode("latin-1") - ) + escaped_text = self.font.escape_text(mapped_text) ret += f"({escaped_text}) Tj" return ret @@ -325,7 +323,7 @@ def adjust_pos(pos): ): if ti["mapped_char"] is None: # Missing glyph continue - char = chr(ti["mapped_char"]).encode("utf-16-be").decode("latin-1") + char = self.font.escape_text(chr(ti["mapped_char"])) if ti["x_offset"] != 0 or ti["y_offset"] != 0: if text: ret += f"({escape_parens(text)}) Tj " diff --git a/fpdf/linearization.py b/fpdf/linearization.py index 016854ec9..d565e7cfb 100644 --- a/fpdf/linearization.py +++ b/fpdf/linearization.py @@ -163,7 +163,6 @@ def bufferize(self): # = resources, that are referenced from more than one page but [not] from the first page pages_root_obj = self._add_pages_root() sig_annotation_obj = self._add_annotations_as_objects() - font_objs_per_index = self._add_fonts() img_objs_per_index = self._add_images() gfxstate_objs_per_name = self._add_gfxstates() shading_objs_per_name = self._add_shadings() diff --git a/fpdf/output.py b/fpdf/output.py index ecd3d9613..925112207 100644 --- a/fpdf/output.py +++ b/fpdf/output.py @@ -18,6 +18,7 @@ from .annotations import PDFAnnotation from .enums import PDFResourceType, PageLabelStyle, SignatureFlag from .errors import FPDFException +from .font_type_3 import Type3Font from .line_break import TotalPagesSubstitutionFragment from .image_datastructures import RasterImageInfo from .outline import build_outline_objs @@ -93,6 +94,85 @@ def __init__(self): self.supplement = 0 +class PDFType3Font(PDFObject): + def __init__(self, font3: "Type3Font"): + super().__init__() + self._font3 = font3 + self.type = Name("Font") + self.name = Name(f"MPDFAA+{font3.base_font.name}") + self.subtype = Name("Type3") + self.font_b_box = ( + f"[{self._font3.base_font.ttfont['head'].xMin * self._font3.scale:.0f}" + f" {self._font3.base_font.ttfont['head'].yMin * self._font3.scale:.0f}" + f" {self._font3.base_font.ttfont['head'].xMax * self._font3.scale:.0f}" + f" {self._font3.base_font.ttfont['head'].yMax * self._font3.scale:.0f}]" + ) + self.font_matrix = "[0.001 0 0 0.001 0 0]" + self.first_char = min(g.unicode for g in font3.glyphs) + self.last_char = max(g.unicode for g in font3.glyphs) + self.resources = None + self.to_unicode = None + + @property + def char_procs(self): + return pdf_dict( + {f"/{g.glyph_name}": f"{g.obj_id} 0 R" for g in self._font3.glyphs} + ) + + @property + def encoding(self): + return pdf_dict( + { + Name("/Type"): Name("/Encoding"), + Name("/Differences"): self.differences_table(), + } + ) + + @property + def widths(self): + sorted_glyphs = sorted(self._font3.glyphs, key=lambda glyph: glyph.unicode) + # Find the range of unicode values + min_unicode = sorted_glyphs[0].unicode + max_unicode = sorted_glyphs[-1].unicode + + # Initialize widths array with zeros + widths = [0] * (max_unicode + 1 - min_unicode) + + # Populate the widths array + for glyph in sorted_glyphs: + widths[glyph.unicode - min_unicode] = round( + glyph.glyph_width * self._font3.scale + 0.001 + ) + return pdf_list([str(glyph_width) for glyph_width in widths]) + + def generate_resources(self, img_objs_per_index, gfxstate_objs_per_name): + resources = "<<" + objects = " ".join( + f"/I{img} {img_objs_per_index[img].id} 0 R" + for img in self._font3.images_used + ) + resources += f"/XObject <<{objects}>>" if len(objects) > 0 else "" + + ext_g_state = " ".join( + f"/{name} {gfxstate_obj.id} 0 R" + for name, gfxstate_obj in gfxstate_objs_per_name.items() + if name in self._font3.graphics_style_used + ) + resources += f"/ExtGState <<{ext_g_state}>>" if len(ext_g_state) > 0 else "" + resources += ">>" + self.resources = resources + + def differences_table(self): + sorted_glyphs = sorted(self._font3.glyphs, key=lambda glyph: glyph.unicode) + return ( + "[" + + "\n".join( + f"{glyph.unicode} /{glyph.glyph_name}" for glyph in sorted_glyphs + ) + + "]" + ) + + class PDFInfo(PDFObject): def __init__( self, @@ -671,9 +751,65 @@ def _add_annotations_as_objects(self): sig_annotation_obj = annot_obj return sig_annotation_obj - def _add_fonts(self): + def _add_fonts(self, image_objects_per_index, gfxstate_objs_per_name): font_objs_per_index = {} for font in sorted(self.fpdf.fonts.values(), key=lambda font: font.i): + + # type 3 font + if font.type == "TTF" and font.color_font: + for glyph in font.color_font.glyphs: + glyph.obj_id = self._add_pdf_obj( + PDFContentStream(contents=glyph.glyph, compress=False), "fonts" + ) + bfChar = [] + def format_code(unicode): + if unicode > 0xFFFF: + # Calculate surrogate pair + code_high = 0xD800 | (unicode - 0x10000) >> 10 + code_low = 0xDC00 | (unicode & 0x3FF) + return f"{code_high:04X}{code_low:04X}" + return f"{unicode:04X}" + + for glyph, code_mapped in font.subset.items(): + if len(glyph.unicode) == 0: + continue + bfChar.append( + f'<{code_mapped:02X}> <{"".join(format_code(code) for code in glyph.unicode)}>\n' + ) + + to_unicode_obj = PDFContentStream( + "/CIDInit /ProcSet findresource begin\n" + "12 dict begin\n" + "begincmap\n" + "/CIDSystemInfo\n" + "<> def\n" + "/CMapName /Adobe-Identity-UCS def\n" + "/CMapType 2 def\n" + "1 begincodespacerange\n" + "<00> \n" + "endcodespacerange\n" + f"{len(bfChar)} beginbfchar\n" + f"{''.join(bfChar)}" + "endbfchar\n" + "endcmap\n" + "CMapName currentdict /CMap defineresource pop\n" + "end\n" + "end" + ) + self._add_pdf_obj(to_unicode_obj, "fonts") + + t3_font_obj = PDFType3Font(font.color_font) + t3_font_obj.to_unicode = pdf_ref(to_unicode_obj.id) + t3_font_obj.generate_resources( + image_objects_per_index, gfxstate_objs_per_name + ) + self._add_pdf_obj(t3_font_obj, "fonts") + font_objs_per_index[font.i] = t3_font_obj + continue + # Standard font if font.type == "core": encoding = ( @@ -899,7 +1035,7 @@ def _add_image(self, info): decode=decode, decode_parms=decode_parms, ) - self._add_pdf_obj(img_obj, "images") + info["obj_id"] = self._add_pdf_obj(img_obj, "images") # Soft mask if self.fpdf.allow_images_transparency and "smask" in info: @@ -956,9 +1092,11 @@ def _add_patterns(self): return pattern_objs_per_name def _insert_resources(self, page_objs): - font_objs_per_index = self._add_fonts() img_objs_per_index = self._add_images() gfxstate_objs_per_name = self._add_gfxstates() + font_objs_per_index = self._add_fonts( + img_objs_per_index, gfxstate_objs_per_name + ) shading_objs_per_name = self._add_shadings() pattern_objs_per_name = self._add_patterns() # Insert /Resources dicts: diff --git a/fpdf/svg.py b/fpdf/svg.py index 503354ca2..a15122db7 100644 --- a/fpdf/svg.py +++ b/fpdf/svg.py @@ -11,7 +11,6 @@ from typing import NamedTuple from fontTools.svgLib.path import parse_path -from fontTools.pens.basePen import BasePen from .enums import PathPaintRule @@ -31,6 +30,7 @@ GraphicsContext, GraphicsStyle, PaintedPath, + PathPen, ClippingPath, Transform, ) @@ -551,61 +551,6 @@ def convert_transforms(tfstr): return transform -class PathPen(BasePen): - def __init__(self, pdf_path, *args, **kwargs): - self.pdf_path = pdf_path - self.last_was_line_to = False - self.first_is_move = None - super().__init__(*args, **kwargs) - - def _moveTo(self, pt): - self.pdf_path.move_to(*pt) - self.last_was_line_to = False - if self.first_is_move is None: - self.first_is_move = True - - def _lineTo(self, pt): - self.pdf_path.line_to(*pt) - self.last_was_line_to = True - if self.first_is_move is None: - self.first_is_move = False - - def _curveToOne(self, pt1, pt2, pt3): - self.pdf_path.curve_to( - x1=pt1[0], y1=pt1[1], x2=pt2[0], y2=pt2[1], x3=pt3[0], y3=pt3[1] - ) - self.last_was_line_to = False - if self.first_is_move is None: - self.first_is_move = False - - def _qCurveToOne(self, pt1, pt2): - self.pdf_path.quadratic_curve_to(x1=pt1[0], y1=pt1[1], x2=pt2[0], y2=pt2[1]) - self.last_was_line_to = False - if self.first_is_move is None: - self.first_is_move = False - - def arcTo(self, rx, ry, rotation, arc, sweep, end): - self.pdf_path.arc_to( - rx=rx, - ry=ry, - rotation=rotation, - large_arc=arc, - positive_sweep=sweep, - x=end[0], - y=end[1], - ) - self.last_was_line_to = False - if self.first_is_move is None: - self.first_is_move = False - - def _closePath(self): - # The fonttools parser inserts an unnecessary explicit line back to the start - # point of the path before actually closing it. Let's get rid of that again. - if self.last_was_line_to: - self.pdf_path.remove_last_path_element() - self.pdf_path.close() - - @force_nodocument def svg_path_converter(pdf_path, svg_path): pen = PathPen(pdf_path) @@ -688,7 +633,7 @@ def extract_shape_info(self, root_tag): if viewbox is None: self.viewbox = None else: - viewbox.strip() + viewbox = viewbox.strip() vx, vy, vw, vh = [float(num) for num in NUMBER_SPLIT.split(viewbox)] if (vw < 0) or (vh < 0): raise ValueError(f"invalid negative width/height in viewbox {viewbox}") diff --git a/test/color_font/BungeeColor-Regular-COLRv0.ttf b/test/color_font/BungeeColor-Regular-COLRv0.ttf new file mode 100644 index 000000000..50b377c86 Binary files /dev/null and b/test/color_font/BungeeColor-Regular-COLRv0.ttf differ diff --git a/test/color_font/BungeeColor-Regular-SVG.ttf b/test/color_font/BungeeColor-Regular-SVG.ttf new file mode 100644 index 000000000..9dd9889d4 Binary files /dev/null and b/test/color_font/BungeeColor-Regular-SVG.ttf differ diff --git a/test/color_font/BungeeColor-Regular_sbix_MacOS.ttf b/test/color_font/BungeeColor-Regular_sbix_MacOS.ttf new file mode 100644 index 000000000..fcbe1bd99 Binary files /dev/null and b/test/color_font/BungeeColor-Regular_sbix_MacOS.ttf differ diff --git a/test/color_font/BungeeSpice-Regular-COLRv1.ttf b/test/color_font/BungeeSpice-Regular-COLRv1.ttf new file mode 100644 index 000000000..4b9cf4115 Binary files /dev/null and b/test/color_font/BungeeSpice-Regular-COLRv1.ttf differ diff --git a/test/color_font/Compyx-Regular-SBIX.ttf b/test/color_font/Compyx-Regular-SBIX.ttf new file mode 100644 index 000000000..1e4204fc8 Binary files /dev/null and b/test/color_font/Compyx-Regular-SBIX.ttf differ diff --git a/test/color_font/Gilbert-Color Bold SVG.otf b/test/color_font/Gilbert-Color Bold SVG.otf new file mode 100644 index 000000000..f21f9a173 Binary files /dev/null and b/test/color_font/Gilbert-Color Bold SVG.otf differ diff --git a/test/color_font/NotoColorEmoji-CBDT.ttf b/test/color_font/NotoColorEmoji-CBDT.ttf new file mode 100644 index 000000000..d71fc7ac3 Binary files /dev/null and b/test/color_font/NotoColorEmoji-CBDT.ttf differ diff --git a/test/color_font/Twemoji.Mozilla.ttf b/test/color_font/Twemoji.Mozilla.ttf new file mode 100644 index 000000000..9f45178e9 Binary files /dev/null and b/test/color_font/Twemoji.Mozilla.ttf differ diff --git a/test/color_font/__init__.py b/test/color_font/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/color_font/cbdt_noto_color_emoji.pdf b/test/color_font/cbdt_noto_color_emoji.pdf new file mode 100644 index 000000000..6f107ec0f Binary files /dev/null and b/test/color_font/cbdt_noto_color_emoji.pdf differ diff --git a/test/color_font/cbdt_noto_color_emoji_shaping.pdf b/test/color_font/cbdt_noto_color_emoji_shaping.pdf new file mode 100644 index 000000000..e0924e9e6 Binary files /dev/null and b/test/color_font/cbdt_noto_color_emoji_shaping.pdf differ diff --git a/test/color_font/cbdt_noto_emoji_text.pdf b/test/color_font/cbdt_noto_emoji_text.pdf new file mode 100644 index 000000000..ff59d7918 Binary files /dev/null and b/test/color_font/cbdt_noto_emoji_text.pdf differ diff --git a/test/color_font/colrv0-twemoji.pdf b/test/color_font/colrv0-twemoji.pdf new file mode 100644 index 000000000..402ecb29c Binary files /dev/null and b/test/color_font/colrv0-twemoji.pdf differ diff --git a/test/color_font/colrv0-twemoji_shaping.pdf b/test/color_font/colrv0-twemoji_shaping.pdf new file mode 100644 index 000000000..689c557f1 Binary files /dev/null and b/test/color_font/colrv0-twemoji_shaping.pdf differ diff --git a/test/color_font/colrv0-twemoji_text.pdf b/test/color_font/colrv0-twemoji_text.pdf new file mode 100644 index 000000000..66610a935 Binary files /dev/null and b/test/color_font/colrv0-twemoji_text.pdf differ diff --git a/test/color_font/sbix_bungee.pdf b/test/color_font/sbix_bungee.pdf new file mode 100644 index 000000000..9b2abf2fa Binary files /dev/null and b/test/color_font/sbix_bungee.pdf differ diff --git a/test/color_font/sbix_compyx.pdf b/test/color_font/sbix_compyx.pdf new file mode 100644 index 000000000..52aee234a Binary files /dev/null and b/test/color_font/sbix_compyx.pdf differ diff --git a/test/color_font/svg_bungee.pdf b/test/color_font/svg_bungee.pdf new file mode 100644 index 000000000..8f03096ce Binary files /dev/null and b/test/color_font/svg_bungee.pdf differ diff --git a/test/color_font/svg_gilbert.pdf b/test/color_font/svg_gilbert.pdf new file mode 100644 index 000000000..4930495ba Binary files /dev/null and b/test/color_font/svg_gilbert.pdf differ diff --git a/test/color_font/svg_twitter_emoji_shaping.pdf b/test/color_font/svg_twitter_emoji_shaping.pdf new file mode 100644 index 000000000..080cf9e6a Binary files /dev/null and b/test/color_font/svg_twitter_emoji_shaping.pdf differ diff --git a/test/color_font/svg_twitter_emoji_text.pdf b/test/color_font/svg_twitter_emoji_text.pdf new file mode 100644 index 000000000..5ea3d800e Binary files /dev/null and b/test/color_font/svg_twitter_emoji_text.pdf differ diff --git a/test/color_font/test_cbdt.py b/test/color_font/test_cbdt.py new file mode 100644 index 000000000..b41ff8f1a --- /dev/null +++ b/test/color_font/test_cbdt.py @@ -0,0 +1,50 @@ +from pathlib import Path + +from fpdf import FPDF +from test.conftest import assert_pdf_equal + +HERE = Path(__file__).resolve().parent +FONTS_DIR = HERE.parent / "fonts" + + +def test_noto_color_emoji(tmp_path): + pdf = FPDF() + pdf.add_font("NotoCBDT", "", HERE / "NotoColorEmoji-CBDT.ttf") + pdf.add_page() + test_text = "πŸ˜‚β€πŸ€£πŸ‘πŸ˜­πŸ™πŸ˜˜πŸ₯°πŸ˜πŸ˜Š" + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Noto Color Emoji (CBDT)", new_x="lmargin", new_y="next") + pdf.cell(text="Top 10 emojis:", new_x="right", new_y="top") + pdf.set_font("NotoCBDT", "", 24) + pdf.cell(text=test_text, new_x="lmargin", new_y="next") + assert_pdf_equal(pdf, HERE / "cbdt_noto_color_emoji.pdf", tmp_path) + + +def test_noto_emoji_shaping(tmp_path): + pdf = FPDF() + pdf.add_font("NotoCBDT", "", HERE / "NotoColorEmoji-CBDT.ttf") + pdf.add_page() + combined_emojis = "πŸ‡«πŸ‡· πŸ‡ΊπŸ‡Έ πŸ‡¨πŸ‡¦ πŸ§‘ πŸ§‘πŸ½ πŸ§‘πŸΏ" + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Emojis without text shaping:", new_x="lmargin", new_y="next") + pdf.set_font("NotoCBDT", "", 24) + pdf.multi_cell(w=pdf.epw, text=combined_emojis, new_x="lmargin", new_y="next") + pdf.ln() + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Emojis with text shaping:", new_x="lmargin", new_y="next") + pdf.set_font("NotoCBDT", "", 24) + pdf.set_text_shaping(True) + pdf.multi_cell(w=pdf.epw, text=combined_emojis, new_x="lmargin", new_y="next") + assert_pdf_equal(pdf, HERE / "cbdt_noto_color_emoji_shaping.pdf", tmp_path) + + +def test_noto_emoji_text(tmp_path): + text = "Remember the days when we had to rely on simple text-based emoticons like :-) or :D to show our feelings online? Those little symbols paved the way for a whole universe of expressive visuals 🀯. As technology advanced πŸš€ and mobile devices became more powerful πŸ“±, the emoticon evolved into the now-iconic emoji 🚦😍! Suddenly, instead of typing <3 for love, we could send an actual heart ❀️—and a million other icons, too. From smiling faces 😊 to dancing humans πŸ’ƒ, from tiny pizzas πŸ• to entire flags 🌍, emojis quickly took over every conversation! Now, we can convey jokes πŸ€ͺ, excitement 🀩, or even complicated feelings πŸ€” with just a tap or two. Looking back, who knew those humble :-P and ;-) would evolve into the expressive rainbow of emojis 🌈 that color our digital world today?" + pdf = FPDF() + pdf.add_font("Roboto", "", FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("NotoCBDT", "", HERE / "NotoColorEmoji-CBDT.ttf") + pdf.set_font("Roboto", "", 24) + pdf.set_fallback_fonts(["NotoCBDT"]) + pdf.add_page() + pdf.multi_cell(w=pdf.epw, text=text) + assert_pdf_equal(pdf, HERE / "cbdt_noto_emoji_text.pdf", tmp_path) diff --git a/test/color_font/test_colr.py b/test/color_font/test_colr.py new file mode 100644 index 000000000..a2021a9aa --- /dev/null +++ b/test/color_font/test_colr.py @@ -0,0 +1,53 @@ +from pathlib import Path + +from fpdf import FPDF +from test.conftest import assert_pdf_equal + +HERE = Path(__file__).resolve().parent +FONTS_DIR = HERE.parent / "fonts" + + +def test_twemoji(tmp_path): + # Twemoji - Mozilla build of the Twitter emojis on COLR format + # Apache 2.0 license + # https://github.com/mozilla/twemoji-colr + pdf = FPDF() + pdf.add_font("Twemoji", "", HERE / "Twemoji.Mozilla.ttf") + pdf.add_page() + test_text = "πŸ˜‚β€πŸ€£πŸ‘πŸ˜­πŸ™πŸ˜˜πŸ₯°πŸ˜πŸ˜Š" + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Twemoi (COLRv0)", new_x="lmargin", new_y="next") + pdf.cell(text="Top 10 emojis:", new_x="right", new_y="top") + pdf.set_font("Twemoji", "", 24) + pdf.cell(text=test_text, new_x="lmargin", new_y="next") + assert_pdf_equal(pdf, HERE / "colrv0-twemoji.pdf", tmp_path) + + +def test_twemoji_shaping(tmp_path): + pdf = FPDF() + pdf.add_font("Twemoji", "", HERE / "Twemoji.Mozilla.ttf") + pdf.add_page() + combined_emojis = "πŸ‡«πŸ‡· πŸ‡ΊπŸ‡Έ πŸ‡¨πŸ‡¦ πŸ§‘ πŸ§‘πŸ½ πŸ§‘πŸΏ" + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Emojis without text shaping:", new_x="lmargin", new_y="next") + pdf.set_font("Twemoji", "", 24) + pdf.multi_cell(w=pdf.epw, text=combined_emojis, new_x="lmargin", new_y="next") + pdf.ln() + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Emojis with text shaping:", new_x="lmargin", new_y="next") + pdf.set_font("Twemoji", "", 24) + pdf.set_text_shaping(True) + pdf.multi_cell(w=pdf.epw, text=combined_emojis, new_x="lmargin", new_y="next") + assert_pdf_equal(pdf, HERE / "colrv0-twemoji_shaping.pdf", tmp_path) + + +def test_twemoji_text(tmp_path): + text = "Remember the days when we had to rely on simple text-based emoticons like :-) or :D to show our feelings online? Those little symbols paved the way for a whole universe of expressive visuals 🀯. As technology advanced πŸš€ and mobile devices became more powerful πŸ“±, the emoticon evolved into the now-iconic emoji 🚦😍! Suddenly, instead of typing <3 for love, we could send an actual heart ❀️—and a million other icons, too. From smiling faces 😊 to dancing humans πŸ’ƒ, from tiny pizzas πŸ• to entire flags 🌍, emojis quickly took over every conversation! Now, we can convey jokes πŸ€ͺ, excitement 🀩, or even complicated feelings πŸ€” with just a tap or two. Looking back, who knew those humble :-P and ;-) would evolve into the expressive rainbow of emojis 🌈 that color our digital world today?" + pdf = FPDF() + pdf.add_font("Roboto", "", FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("Twemoji", "", HERE / "Twemoji.Mozilla.ttf") + pdf.set_font("Roboto", "", 24) + pdf.set_fallback_fonts(["Twemoji"]) + pdf.add_page() + pdf.multi_cell(w=pdf.epw, text=text) + assert_pdf_equal(pdf, HERE / "colrv0-twemoji_text.pdf", tmp_path) diff --git a/test/color_font/test_sbix.py b/test/color_font/test_sbix.py new file mode 100644 index 000000000..191a2d622 --- /dev/null +++ b/test/color_font/test_sbix.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from fpdf import FPDF +from test.conftest import LOREM_IPSUM, assert_pdf_equal + +HERE = Path(__file__).resolve().parent +FONTS_DIR = HERE.parent / "fonts" + + +def test_sbix_compyx(tmp_path): + # Compyx - OFL license + # https://github.com/RoelN/Compyx + + pdf = FPDF() + pdf.add_font("Compyx", "", HERE / "Compyx-Regular-SBIX.ttf") + + pdf.add_page() + pdf.set_font("Compyx", size=16) + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.lower(), align="L") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.lower(), align="R") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.lower(), align="J") + + assert_pdf_equal(pdf, HERE / "sbix_compyx.pdf", tmp_path) + + +def test_sbix_bungee(tmp_path): + # Bungee Color - OFL license + # https://github.com/djrrb/Bungee + + pdf = FPDF() + pdf.add_font("Bungee", "", HERE / "BungeeColor-Regular_sbix_MacOS.ttf") + + pdf.add_page() + pdf.set_font("Bungee", size=16) + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.upper(), align="L") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.upper(), align="R") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.upper(), align="J") + + assert_pdf_equal(pdf, HERE / "sbix_bungee.pdf", tmp_path) diff --git a/test/color_font/test_svg.py b/test/color_font/test_svg.py new file mode 100644 index 000000000..1614e371d --- /dev/null +++ b/test/color_font/test_svg.py @@ -0,0 +1,74 @@ +from pathlib import Path + +from fpdf import FPDF +from test.conftest import LOREM_IPSUM, assert_pdf_equal + +HERE = Path(__file__).resolve().parent +FONTS_DIR = HERE.parent / "fonts" + + +def test_gilbert_color(tmp_path): + # Gilbert Color - Creative Commons license + # https://github.com/Fontself/TypeWithPride + + pdf = FPDF() + pdf.add_font("Gilbert", "", HERE / "Gilbert-Color Bold SVG.otf") + + pdf.add_page() + pdf.set_font("Gilbert", size=16) + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.lower(), align="L") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.lower(), align="R") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.lower(), align="J") + + assert_pdf_equal(pdf, HERE / "svg_gilbert.pdf", tmp_path) + + +def test_svg_bungee(tmp_path): + # Bungee Color - OFL license + # https://github.com/djrrb/Bungee + + pdf = FPDF() + pdf.add_font("Bungee", "", HERE / "BungeeColor-Regular-SVG.ttf") + pdf.add_page() + pdf.set_font("Bungee", size=16) + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.upper(), align="L") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.upper(), align="R") + pdf.ln() + pdf.multi_cell(w=pdf.epw, text=LOREM_IPSUM.upper(), align="J") + + assert_pdf_equal(pdf, HERE / "svg_bungee.pdf", tmp_path) + + +def test_twitter_emoji_shaping(tmp_path): + # Twitter Emoji font - MIT license + # https://github.com/twitter/twemoji + pdf = FPDF() + pdf.add_font("TwitterEmoji", "", FONTS_DIR / "TwitterEmoji.ttf") + pdf.add_page() + combined_emojis = "πŸ‡«πŸ‡· πŸ‡ΊπŸ‡Έ πŸ‡¨πŸ‡¦ πŸ§‘ πŸ§‘πŸ½ πŸ§‘πŸΏ" + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Emojis without text shaping:", new_x="lmargin", new_y="next") + pdf.set_font("TwitterEmoji", "", 24) + pdf.multi_cell(w=pdf.epw, text=combined_emojis, new_x="lmargin", new_y="next") + pdf.ln() + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Emojis with text shaping:", new_x="lmargin", new_y="next") + pdf.set_font("TwitterEmoji", "", 24) + pdf.set_text_shaping(True) + pdf.multi_cell(w=pdf.epw, text=combined_emojis, new_x="lmargin", new_y="next") + assert_pdf_equal(pdf, HERE / "svg_twitter_emoji_shaping.pdf", tmp_path) + + +def test_twitter_emoji_text(tmp_path): + text = "Remember the days when we had to rely on simple text-based emoticons like :-) or :D to show our feelings online? Those little symbols paved the way for a whole universe of expressive visuals 🀯. As technology advanced πŸš€ and mobile devices became more powerful πŸ“±, the emoticon evolved into the now-iconic emoji 🚦😍! Suddenly, instead of typing <3 for love, we could send an actual heart ❀️—and a million other icons, too. From smiling faces 😊 to dancing humans πŸ’ƒ, from tiny pizzas πŸ• to entire flags 🌍, emojis quickly took over every conversation! Now, we can convey jokes πŸ€ͺ, excitement 🀩, or even complicated feelings πŸ€” with just a tap or two. Looking back, who knew those humble :-P and ;-) would evolve into the expressive rainbow of emojis 🌈 that color our digital world today?" + pdf = FPDF() + pdf.add_font("Roboto", "", FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("TwitterEmoji", "", FONTS_DIR / "TwitterEmoji.ttf") + pdf.set_font("Roboto", "", 24) + pdf.set_fallback_fonts(["TwitterEmoji"]) + pdf.add_page() + pdf.multi_cell(w=pdf.epw, text=text) + assert_pdf_equal(pdf, HERE / "svg_twitter_emoji_text.pdf", tmp_path)