Skip to content

Commit 46d8dfc

Browse files
committed
draft support for color fonts
svg font fixes initial support for svg font fix duplicate resource entries typing for python 3.8 draft
1 parent 4a64da0 commit 46d8dfc

13 files changed

+491
-8
lines changed

fpdf/font_type_3.py

+280
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import logging
2+
3+
from typing import List, Tuple, TYPE_CHECKING
4+
from io import BytesIO
5+
from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics
6+
7+
from .image_datastructures import RasterImageInfo, VectorImageInfo
8+
9+
10+
11+
if TYPE_CHECKING:
12+
from .fpdf import FPDF
13+
from .fonts import TTFFont
14+
15+
16+
LOGGER = logging.getLogger(__name__)
17+
18+
19+
class Type3FontGlyph:
20+
# RAM usage optimization:
21+
__slots__ = (
22+
"obj_id",
23+
"glyph_id",
24+
"unicode",
25+
"glyph_name",
26+
"glyph_width",
27+
"glyph",
28+
"_glyph_bounds",
29+
)
30+
obj_id: int
31+
glyph_id: int
32+
unicode: Tuple
33+
glyph_name: str
34+
glyph_width: int
35+
glyph: str
36+
_glyph_bounds: Tuple[int, int, int, int]
37+
38+
def __init__(self):
39+
pass
40+
41+
def __hash__(self):
42+
return self.glyph_id
43+
44+
45+
class Type3Font:
46+
47+
def __init__(self, fpdf: "FPDF", base_font: "TTFFont"):
48+
self.i = 1
49+
self.type = "type3"
50+
self.fpdf = fpdf
51+
self.base_font = base_font
52+
self.upem = self.base_font.ttfont["head"].unitsPerEm
53+
self.scale = 1000 / self.upem
54+
self.images_used = set()
55+
self.graphics_style_used = set()
56+
self.glyphs: List[Type3FontGlyph] = []
57+
58+
@classmethod
59+
def get_notdef_glyph(cls, glyph_id) -> Type3FontGlyph:
60+
notdef = Type3FontGlyph()
61+
notdef.glyph_id = glyph_id
62+
notdef.unicode = 0
63+
notdef.glyph_name = ".notdef"
64+
notdef.glyph_width = 0
65+
notdef.glyph = "0 0 d0"
66+
return notdef
67+
68+
def get_space_glyph(self, glyph_id) -> Type3FontGlyph:
69+
space = Type3FontGlyph()
70+
space.glyph_id = glyph_id
71+
space.unicode = 0x20
72+
space.glyph_name = "space"
73+
space.glyph_width = self.base_font.desc.missing_width
74+
space.glyph = f"{space.glyph_width} 0 d0"
75+
return space
76+
77+
def load_glyphs(self):
78+
for glyph, char_id in self.base_font.subset.items():
79+
if not self.glyph_exists(glyph.glyph_name):
80+
# print(f"notdef id {char_id} name {glyph.glyph_name}")
81+
if char_id == 0x20:
82+
self.glyphs.append(self.get_space_glyph(char_id))
83+
else:
84+
self.glyphs.append(self.get_notdef_glyph(char_id))
85+
continue
86+
self.add_glyph(glyph.glyph_name, char_id)
87+
88+
def add_glyph(self, glyph_name, char_id):
89+
g = Type3FontGlyph()
90+
g.glyph_id = char_id
91+
g.unicode = char_id
92+
g.glyph_name = glyph_name
93+
self.load_glyph_image(g)
94+
self.glyphs.append(g)
95+
96+
def load_glyph_image(self, glyph: Type3FontGlyph):
97+
x_min, y_min, x_max, y_max, _, glyph_bitmap = self.read_glyph_data(
98+
glyph.glyph_name
99+
)
100+
bio = BytesIO(glyph_bitmap)
101+
bio.seek(0)
102+
_, img, info = self.fpdf.preload_image(bio, None)
103+
if isinstance(info, VectorImageInfo):
104+
w = round(
105+
self.base_font.ttfont["hmtx"].metrics[glyph.glyph_name][0] * self.scale
106+
+ 0.001
107+
)
108+
# _, _, path = img.transform_to_rect_viewport(self.fpdf.k, None, None, align_viewbox=False)
109+
_, _, path = img.transform_to_page_viewport(
110+
pdf=self.fpdf, align_viewbox=False
111+
)
112+
output_stream = self.fpdf.draw_vector_glyph(path, self)
113+
glyph.glyph = (
114+
f"{w} 0 d0\n"
115+
"q\n"
116+
# f"1 0 0 1 {x_min * self.scale} {y_min * self.scale} cm\n"
117+
f"{output_stream}\n"
118+
"Q"
119+
)
120+
glyph.glyph_width = x_max
121+
elif isinstance(info, RasterImageInfo):
122+
glyph.glyph = (
123+
f"{x_max * self.scale} 0 d0\n"
124+
"q\n"
125+
f"{x_max * self.scale} 0 0 {(-y_min + y_max) * self.scale} {x_min * self.scale} {y_min * self.scale} cm\n"
126+
f"/I{info['i']} Do\nQ"
127+
)
128+
glyph.glyph_width = x_max
129+
self.images_used.add(info["i"])
130+
131+
def glyph_exists(self, glyph_name: str) -> bool:
132+
raise NotImplementedError("Method must be implemented on child class")
133+
134+
def read_glyph_data(self, glyph_name):
135+
raise NotImplementedError("Method must be implemented on child class")
136+
137+
138+
class SVGColorFont(Type3Font):
139+
140+
def glyph_exists(self, glyph_name):
141+
glyph_id = self.base_font.ttfont.getGlyphID(glyph_name)
142+
return any(
143+
svg_doc.startGlyphID <= glyph_id <= svg_doc.endGlyphID
144+
for svg_doc in self.base_font.ttfont["SVG "].docList
145+
)
146+
147+
def read_glyph_data(self, glyph_name: str) -> BytesIO:
148+
glyph_id = self.base_font.ttfont.getGlyphID(glyph_name)
149+
glyph_svg_data = None
150+
for svg_doc in self.base_font.ttfont["SVG "].docList:
151+
if svg_doc.startGlyphID <= glyph_id <= svg_doc.endGlyphID:
152+
glyph_svg_data = svg_doc.data.encode("utf-8")
153+
154+
x_min, y_min, x_max, y_max = self.get_glyph_bounds(glyph_name)
155+
x_min = round(x_min) # * self.upem / ppem)
156+
y_min = round(y_min) # * self.upem / ppem)
157+
x_max = round(x_max) # * self.upem / ppem)
158+
y_max = round(y_max) # * self.upem / ppem)
159+
160+
return x_min, y_min, x_max, y_max, x_max, glyph_svg_data
161+
162+
def get_glyph_bounds(self, glyph_name: str) -> Tuple[int, int, int, int]:
163+
glyph_id = self.base_font.ttfont.getGlyphID(glyph_name)
164+
x, y, w, h = self.base_font.hbfont.get_glyph_extents(glyph_id)
165+
# convert from HB's x/y_bearing + extents to xMin, yMin, xMax, yMax
166+
y += h
167+
h = -h
168+
w += x
169+
h += y
170+
# print(f"harfbuzz values: {x}, {y}, {w}, {h}")
171+
return x, y, w, h
172+
173+
174+
class CBDTColorFont(Type3Font):
175+
176+
# Only looking at the first strike - Need to look all strikes available on the CBLC table first?
177+
178+
def glyph_exists(self, glyph_name):
179+
return glyph_name in self.base_font.ttfont["CBDT"].strikeData[0]
180+
181+
def read_glyph_data(self, glyph_name):
182+
ppem = self.base_font.ttfont["CBLC"].strikes[0].bitmapSizeTable.ppemX
183+
glyph = self.base_font.ttfont["CBDT"].strikeData[0][glyph_name]
184+
glyph_bitmap = glyph.data[9:]
185+
metrics = glyph.metrics
186+
if isinstance(metrics, SmallGlyphMetrics):
187+
x_min = round(metrics.BearingX * self.upem / ppem)
188+
y_min = round((metrics.BearingY - metrics.height) * self.upem / ppem)
189+
x_max = round(metrics.width * self.upem / ppem)
190+
y_max = round(metrics.BearingY * self.upem / ppem)
191+
advance = round(metrics.Advance * self.upem / ppem)
192+
elif isinstance(metrics, BigGlyphMetrics):
193+
x_min = round(metrics.horiBearingX * self.upem / ppem)
194+
y_min = round((metrics.horiBearingY - metrics.height) * self.upem / ppem)
195+
x_max = round(metrics.width * self.upem / ppem)
196+
y_max = round(metrics.horiBearingY * self.upem / ppem)
197+
advance = round(metrics.horiAdvance * self.upem / ppem)
198+
else: # fallback scenario: use font bounding box
199+
x_min = self.base_font.ttfont["head"].xMin
200+
y_min = self.base_font.ttfont["head"].yNin
201+
x_max = self.base_font.ttfont["head"].xMax
202+
y_max = self.base_font.ttfont["head"].yMax
203+
advance = self.base_font.ttfont["hmtx"].metrics[".notdef"][0]
204+
return x_min, y_min, x_max, y_max, advance, glyph_bitmap
205+
206+
207+
class SBIXColorFont(Type3Font):
208+
209+
def glyph_exists(self, glyph_name):
210+
ppem = list(self.base_font.ttfont["sbix"].strikes.keys())[0]
211+
return (
212+
self.base_font.ttfont["sbix"].strikes[ppem].glyphs.get(glyph_name.upper())
213+
)
214+
215+
def read_glyph_data(self, glyph_name: str) -> BytesIO:
216+
# how to select the ideal ppm?
217+
# print(self.base_font.ttfont["sbix"].strikes.keys())
218+
ppem = list(self.base_font.ttfont["sbix"].strikes.keys())[0]
219+
# print(f"ppem {ppem}")
220+
# print(f'unitsPerEm {self.base_font.ttfont["head"].unitsPerEm}')
221+
# print(
222+
# f'xMin {self.base_font.ttfont["head"].xMin} xMax {self.base_font.ttfont["head"].xMax}'
223+
# )
224+
# print(
225+
# f'yMin {self.base_font.ttfont["head"].yMin} yMax {self.base_font.ttfont["head"].yMax}'
226+
# )
227+
# print(f'glyphDataFormat {self.base_font.ttfont["head"].glyphDataFormat}')
228+
229+
glyph = self.base_font.ttfont["sbix"].strikes[ppem].glyphs.get(glyph_name)
230+
if not glyph:
231+
return None
232+
233+
if glyph.graphicType == "dupe":
234+
return None
235+
# to do - waiting for an example to test
236+
# dupe_char = font.getBestCmap()[glyph.imageData]
237+
# return self.get_color_glyph(dupe_char)
238+
239+
x_min, y_min, x_max, y_max = self.get_glyph_bounds(glyph_name)
240+
x_min = round(x_min * self.upem / ppem)
241+
y_min = round(y_min * self.upem / ppem)
242+
x_max = round(x_max * self.upem / ppem)
243+
y_max = round(y_max * self.upem / ppem)
244+
245+
# graphic type 'pdf' or 'mask' are not supported
246+
return x_min, y_min, x_max, y_max, x_max, glyph.imageData
247+
248+
def get_glyph_bounds(self, glyph_name: str) -> Tuple[int, int, int, int]:
249+
glyph_id = self.base_font.ttfont.getGlyphID(glyph_name)
250+
x, y, w, h = self.base_font.hbfont.get_glyph_extents(glyph_id)
251+
# convert from HB's x/y_bearing + extents to xMin, yMin, xMax, yMax
252+
y += h
253+
h = -h
254+
w += x
255+
h += y
256+
# print(f"harfbuzz values: {x}, {y}, {w}, {h}")
257+
return x, y, w, h
258+
259+
260+
# pylint: disable=too-many-return-statements
261+
def get_color_font_object(fpdf: "FPDF", base_font: "TTFFont") -> Type3Font:
262+
if "CBDT" in base_font.ttfont:
263+
LOGGER.warning("Font %s is a CBLC+CBDT color font", base_font.name)
264+
return CBDTColorFont(fpdf, base_font)
265+
if "EBDT" in base_font.ttfont:
266+
LOGGER.warning("%s - EBLC+EBDT color font is not supported yet", base_font.name)
267+
return None
268+
if "COLR" in base_font.ttfont:
269+
if base_font.ttfont["COLR"].version == 0:
270+
LOGGER.warning("Font %s is a COLRv0 color font", base_font.name)
271+
return None
272+
LOGGER.warning("Font %s is a COLRv1 color font", base_font.name)
273+
return None
274+
if "SVG " in base_font.ttfont:
275+
LOGGER.warning("Font %s is a SVG color font", base_font.name)
276+
return SVGColorFont(fpdf, base_font)
277+
if "sbix" in base_font.ttfont:
278+
LOGGER.warning("Font %s is a SBIX color font", base_font.name)
279+
return SBIXColorFont(fpdf, base_font)
280+
return None

fpdf/fonts.py

+13-4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def __deepcopy__(self, _memo):
3434
from .deprecation import get_stack_level
3535
from .drawing import convert_to_device_color, DeviceGray, DeviceRGB
3636
from .enums import FontDescriptorFlags, TextEmphasis
37+
from .font_type_3 import get_color_font_object
3738
from .syntax import Name, PDFObject
3839
from .util import escape_parens
3940

@@ -218,7 +219,7 @@ class TTFFont:
218219
"name",
219220
"desc",
220221
"glyph_ids",
221-
"hbfont",
222+
"_hbfont",
222223
"up",
223224
"ut",
224225
"cw",
@@ -230,12 +231,14 @@ class TTFFont:
230231
"cmap",
231232
"ttfont",
232233
"missing_glyphs",
234+
"color_font",
233235
)
234236

235237
def __init__(self, fpdf, font_file_path, fontkey, style):
236238
self.i = len(fpdf.fonts) + 1
237239
self.type = "TTF"
238240
self.ttffile = font_file_path
241+
self._hbfont = None
239242
self.fontkey = fontkey
240243

241244
# 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):
317320
self.ut = round(self.ttfont["post"].underlineThickness * self.scale)
318321
self.emphasis = TextEmphasis.coerce(style)
319322
self.subset = SubsetMap(self, [ord(char) for char in sbarr])
323+
self.color_font = get_color_font_object(fpdf, self)
324+
325+
# pylint: disable=no-member
326+
@property
327+
def hbfont(self):
328+
if not self._hbfont:
329+
self._hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile)))
330+
return self._hbfont
320331

321332
def __repr__(self):
322333
return f"TTFFont(i={self.i}, fontkey={self.fontkey})"
323334

324335
def close(self):
325336
self.ttfont.close()
326-
self.hbfont = None
337+
self._hbfont = None
327338

328339
def get_text_width(self, text, font_size_pt, text_shaping_parms):
329340
if text_shaping_parms:
@@ -357,8 +368,6 @@ def perform_harfbuzz_shaping(self, text, font_size_pt, text_shaping_parms):
357368
"""
358369
This method invokes Harfbuzz to perform text shaping of the input string
359370
"""
360-
if not hasattr(self, "hbfont"):
361-
self.hbfont = HarfBuzzFont(hb.Face(hb.Blob.from_file_path(self.ttffile)))
362371
self.hbfont.ptem = font_size_pt
363372
buf = hb.Buffer()
364373
buf.cluster_level = 1

0 commit comments

Comments
 (0)