Skip to content

Commit

Permalink
draft support for color fonts
Browse files Browse the repository at this point in the history
svg font fixes

initial support for svg font

fix duplicate resource entries

typing for python 3.8

draft
  • Loading branch information
andersonhc committed Jan 10, 2025
1 parent 4a64da0 commit 46d8dfc
Show file tree
Hide file tree
Showing 13 changed files with 491 additions and 8 deletions.
280 changes: 280 additions & 0 deletions fpdf/font_type_3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import logging

from typing import List, Tuple, TYPE_CHECKING
from io import BytesIO
from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics

from .image_datastructures import RasterImageInfo, VectorImageInfo



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] = []

@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):
x_min, y_min, x_max, y_max, _, glyph_bitmap = self.read_glyph_data(
glyph.glyph_name
)
bio = BytesIO(glyph_bitmap)
bio.seek(0)
_, img, info = self.fpdf.preload_image(bio, None)
if isinstance(info, VectorImageInfo):
w = round(
self.base_font.ttfont["hmtx"].metrics[glyph.glyph_name][0] * self.scale
+ 0.001
)
# _, _, path = img.transform_to_rect_viewport(self.fpdf.k, None, None, align_viewbox=False)
_, _, path = img.transform_to_page_viewport(
pdf=self.fpdf, align_viewbox=False
)
output_stream = self.fpdf.draw_vector_glyph(path, self)
glyph.glyph = (
f"{w} 0 d0\n"
"q\n"
# f"1 0 0 1 {x_min * self.scale} {y_min * self.scale} cm\n"
f"{output_stream}\n"
"Q"
)
glyph.glyph_width = x_max
elif isinstance(info, RasterImageInfo):
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} {x_min * self.scale} {y_min * self.scale} cm\n"
f"/I{info['i']} Do\nQ"
)
glyph.glyph_width = x_max
self.images_used.add(info["i"])

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")

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)

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 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
Loading

0 comments on commit 46d8dfc

Please sign in to comment.