Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Draft) Add Type 3 Fonts Functionality and Support for Color Fonts 🎨 #1305

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 267 additions & 0 deletions fpdf/font_type_3.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 13 additions & 4 deletions fpdf/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -218,7 +219,7 @@ class TTFFont:
"name",
"desc",
"glyph_ids",
"hbfont",
"_hbfont",
"up",
"ut",
"cw",
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion fpdf/linearization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading