diff --git a/fpdf/font_type_3.py b/fpdf/font_type_3.py new file mode 100644 index 000000000..f00ecd506 --- /dev/null +++ b/fpdf/font_type_3.py @@ -0,0 +1,267 @@ +import logging + +from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics + +from typing import List, Tuple, TYPE_CHECKING +from io import BytesIO + +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.resources = [] + self.glyphs: List[Type3FontGlyph] = [] + + def add_resource(self, image_info): + # don't add duplicate + if any(r["i"] == image_info["i"] for r in self.resources): + return + self.resources.append(image_info) + + @classmethod + def get_notdef_glyph(cls, glyph_id) -> Type3FontGlyph: + notdef = Type3FontGlyph() + notdef.glyph_id = glyph_id + notdef.unicode = 0 + notdef.glyph_name = ".notdef" + notdef.glyph_width = 0 + notdef.glyph = "0 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.glyph_width = self.base_font.desc.missing_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): + # print(f"notdef id {char_id} name {glyph.glyph_name}") + if char_id == 0x20: + self.glyphs.append(self.get_space_glyph(char_id)) + else: + 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) + + def load_glyph_image(self, glyph: Type3FontGlyph): + _, y_min, x_max, y_max, _, glyph_bitmap = self.read_glyph_data(glyph.glyph_name) + bio = BytesIO(glyph_bitmap) + bio.seek(0) + # print(f"scale: {self.scale}") + _, _, info = self.fpdf.preload_image(bio, None) + # print(info) + glyph.glyph = ( + f"{x_max * self.scale} 0 d0\n" + "q\n" + f"{x_max * self.scale} 0 0 {(-y_min + y_max) * self.scale} 0 {y_min * self.scale} cm\n" + f"/I{info['i']} Do\nQ" + ) + # print(glyph.glyph) + # print(info) + glyph.glyph_width = x_max + self.add_resource(info) + + def glyph_exists(self, glyph_name: str) -> bool: + raise NotImplementedError("Method must be implemented on child class") + + def read_glyph_data(self, glyph_name): + 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 read_glyph_data(self, glyph_name: str) -> BytesIO: + glyph_id = self.base_font.ttfont.getGlyphID(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") + # print(glyph_svg_data) + + x_min, y_min, x_max, y_max = self.get_glyph_bounds(glyph_name) + x_min = round(x_min) # * self.upem / ppem) + y_min = round(y_min) # * self.upem / ppem) + x_max = round(x_max) # * self.upem / ppem) + y_max = round(y_max) # * self.upem / ppem) + + # graphic type 'pdf' or 'mask' are not supported + return x_min, y_min, x_max, y_max, x_max, glyph_svg_data + + def get_glyph_bounds(self, glyph_name: str) -> Tuple[int, int, int, int]: + glyph_id = self.base_font.ttfont.getGlyphID(glyph_name) + x, y, w, h = self.base_font.hbfont.get_glyph_extents(glyph_id) + # convert from HB's x/y_bearing + extents to xMin, yMin, xMax, yMax + y += h + h = -h + w += x + h += y + # print(f"harfbuzz values: {x}, {y}, {w}, {h}") + return x, y, w, h + + +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 read_glyph_data(self, glyph_name): + ppem = self.base_font.ttfont["CBLC"].strikes[0].bitmapSizeTable.ppemX + glyph = self.base_font.ttfont["CBDT"].strikeData[0][glyph_name] + glyph_bitmap = glyph.data[9:] + metrics = glyph.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) + advance = round(metrics.Advance * 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) + advance = round(metrics.horiAdvance * 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 + advance = self.base_font.ttfont["hmtx"].metrics[".notdef"][0] + return x_min, y_min, x_max, y_max, advance, glyph_bitmap + + +class SBIXColorFont(Type3Font): + + def glyph_exists(self, glyph_name): + ppem = list(self.base_font.ttfont["sbix"].strikes.keys())[0] + return ( + self.base_font.ttfont["sbix"].strikes[ppem].glyphs.get(glyph_name.upper()) + ) + + def read_glyph_data(self, glyph_name: str) -> BytesIO: + # how to select the ideal ppm? + # print(self.base_font.ttfont["sbix"].strikes.keys()) + ppem = list(self.base_font.ttfont["sbix"].strikes.keys())[0] + # print(f"ppem {ppem}") + # print(f'unitsPerEm {self.base_font.ttfont["head"].unitsPerEm}') + # print( + # f'xMin {self.base_font.ttfont["head"].xMin} xMax {self.base_font.ttfont["head"].xMax}' + # ) + # print( + # f'yMin {self.base_font.ttfont["head"].yMin} yMax {self.base_font.ttfont["head"].yMax}' + # ) + # print(f'glyphDataFormat {self.base_font.ttfont["head"].glyphDataFormat}') + + glyph = self.base_font.ttfont["sbix"].strikes[ppem].glyphs.get(glyph_name) + if not glyph: + return None + + if glyph.graphicType == "dupe": + return None + # to do - waiting for an example to test + # dupe_char = font.getBestCmap()[glyph.imageData] + # return self.get_color_glyph(dupe_char) + + x_min, y_min, x_max, y_max = self.get_glyph_bounds(glyph_name) + x_min = round(x_min * self.upem / ppem) + y_min = round(y_min * self.upem / ppem) + x_max = round(x_max * self.upem / ppem) + y_max = round(y_max * self.upem / ppem) + + # graphic type 'pdf' or 'mask' are not supported + return x_min, y_min, x_max, y_max, x_max, glyph.imageData + + def get_glyph_bounds(self, glyph_name: str) -> Tuple[int, int, int, int]: + glyph_id = self.base_font.ttfont.getGlyphID(glyph_name) + x, y, w, h = self.base_font.hbfont.get_glyph_extents(glyph_id) + # convert from HB's x/y_bearing + extents to xMin, yMin, xMax, yMax + y += h + h = -h + w += x + h += y + # print(f"harfbuzz values: {x}, {y}, {w}, {h}") + return x, y, w, h + + +# 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) + return None + LOGGER.warning("Font %s is a COLRv1 color font", base_font.name) + return None + if "SVG " in base_font.ttfont: + LOGGER.warning("Font %s is a SVG color font", base_font.name) + return None # 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 d7ff1194a..b25f0acf0 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -34,6 +34,7 @@ def __deepcopy__(self, _memo): from .deprecation import get_stack_level from .drawing import convert_to_device_color, DeviceGray, DeviceRGB from .enums import FontDescriptorFlags, TextEmphasis +from .font_type_3 import get_color_font_object from .syntax import Name, PDFObject from .util import escape_parens @@ -218,7 +219,7 @@ class TTFFont: "name", "desc", "glyph_ids", - "hbfont", + "_hbfont", "up", "ut", "cw", @@ -230,12 +231,14 @@ class TTFFont: "cmap", "ttfont", "missing_glyphs", + "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 @@ -317,13 +320,21 @@ def __init__(self, fpdf, font_file_path, fontkey, style): self.ut = round(self.ttfont["post"].underlineThickness * self.scale) self.emphasis = TextEmphasis.coerce(style) self.subset = SubsetMap(self, [ord(char) for char in sbarr]) + 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 get_text_width(self, text, font_size_pt, text_shaping_parms): if text_shaping_parms: @@ -357,8 +368,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 diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 17bfba450..1015118a6 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -5289,6 +5289,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/linearization.py b/fpdf/linearization.py index 42878f7af..41dec2c64 100644 --- a/fpdf/linearization.py +++ b/fpdf/linearization.py @@ -163,8 +163,8 @@ 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() + font_objs_per_index = self._add_fonts(img_objs_per_index) gfxstate_objs_per_name = self._add_gfxstates() resources_dict_obj = self._add_resources_dict( font_objs_per_index, img_objs_per_index, gfxstate_objs_per_name diff --git a/fpdf/output.py b/fpdf/output.py index 58a837380..dc07eacfe 100644 --- a/fpdf/output.py +++ b/fpdf/output.py @@ -17,6 +17,7 @@ from .annotations import PDFAnnotation from .enums import 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 @@ -89,6 +90,74 @@ 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 = f"[{1/1000} 0 0 {1/1000} 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 + + @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): + objects = " ".join( + f'/I{img["i"]} {img_objs_per_index[img["i"]].id} 0 R' + for img in self._font3.resources + ) + self.resources = f"<>>>" + + 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, @@ -532,9 +601,22 @@ 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): 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" + ) + t3_font_obj = PDFType3Font(font.color_font) + t3_font_obj.generate_resources(image_objects_per_index) + 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 = ( @@ -760,7 +842,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: @@ -796,9 +878,9 @@ def _add_gfxstates(self): return gfxstate_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) # Insert /Resources dicts: if self.fpdf.single_resources_object: resources_dict_obj = self._add_resources_dict( 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/NotoColorEmoji-CBDT.ttf b/test/color_font/NotoColorEmoji-CBDT.ttf new file mode 100644 index 000000000..cf7a47e96 Binary files /dev/null and b/test/color_font/NotoColorEmoji-CBDT.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/color_font_basic.pdf b/test/color_font/color_font_basic.pdf new file mode 100644 index 000000000..56549d288 Binary files /dev/null and b/test/color_font/color_font_basic.pdf differ diff --git a/test/color_font/test_color_fonts.py b/test/color_font/test_color_fonts.py new file mode 100644 index 000000000..209a37695 --- /dev/null +++ b/test/color_font/test_color_fonts.py @@ -0,0 +1,39 @@ +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_color_fonts(tmp_path): + pdf = FPDF() + # pdf.set_page_background((252,212,255)) + # pdf.set_text_shaping(False) + + pdf.add_font("NotoCBDT", "", HERE / "NotoColorEmoji-CBDT.ttf") + pdf.add_font("TwitterEmoji", "", FONTS_DIR / "TwitterEmoji.ttf") + pdf.add_font("BungeeSBIX", "", HERE / "BungeeColor-Regular_sbix_MacOS.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") + pdf.ln() + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Twitter Emoji (SVG)", new_x="lmargin", new_y="next") + pdf.set_font("TwitterEmoji", "", 24) + pdf.cell(text=test_text, new_x="lmargin", new_y="next") + pdf.ln() + pdf.set_font("helvetica", "", 24) + pdf.cell(text="Bungee color (sbix)", new_x="lmargin", new_y="next") + pdf.set_font("BungeeSBIX", "", 24) + pdf.cell(text="BUNGEE COLOR SBIX", new_x="lmargin", new_y="next") + + assert_pdf_equal(pdf, HERE / "color_font_basic.pdf", tmp_path)