Skip to content

Commit f75ad38

Browse files
authored
Add support for bitmap fonts (#1747)
* implement bitmap font rendering
1 parent a8c061b commit f75ad38

12 files changed

Lines changed: 366 additions & 19 deletions

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*.pdf binary
77
*.p12 binary
88
*.ods binary
9+
*.otb binary
910
*.otf binary
1011
*.ttf binary
1112
*.TTF binary

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
2525
* TTC/OTC face selection via `collection_font_number` in `add_font()`
2626
* CID-keyed CFF font embedding support
2727
* Microsoft Symbol font remapping for non-Unicode cmaps
28+
* support for EBDT/EBLC bitmap fonts
2829
### Fixed
2930
* the `A5` value that could be specified as page `format` to the `FPDF` constructor was slightly incorrect, and the corresponding page dimensions have been fixed. This could lead to a minor change in your documents dimensions if you used this `A5` page format. - _cf._ [issue #1699](https://github.com/py-pdf/fpdf2/issues/1699)
3031
* a bug when rendering empty tables with `INTERNAL` layout, that caused an extra border to be rendered due to an erroneous use of `list.index()` - _cf._ [issue #1669](https://github.com/py-pdf/fpdf2/issues/1669)

docs/EmojisSymbolsDingbats.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Another font supporting emojis is: [twemoji](https://github.com/13rac1/twemoji-c
2525
## Color fonts and emojis
2626
A wide variety of color fonts are supported - SBIX, CBDT/CBLC, SVG, COLRv0 and COLRv1. If a loaded font provides color glyphs, `fpdf2` will render them automatically.
2727

28+
Bitmap-only fonts using EBDT/EBLC (for example, monochrome or grayscale bitmap strikes) are also supported. These are rendered using the current text color, with grayscale values applied as alpha.
29+
2830
To always draw emoji as outline/monochrome even if the font includes color glyphs, set: `FPDF.render_color_fonts = False`
2931

3032
## Symbols

fpdf/drawing.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -909,17 +909,21 @@ def stroke_dash_phase(self, value: InheritType | Number) -> None:
909909
raise TypeError(f"{value} isn't a number or GraphicsStyle.INHERIT")
910910

911911
@property
912-
def soft_mask(self) -> Union[InheritType, "PaintSoftMask"]:
912+
def soft_mask(self) -> Union[InheritType, "PaintSoftMask", "ImageSoftMask"]:
913913
paint_soft_mask = getattr(self, PDFStyleKeys.SOFT_MASK.value, self.INHERIT)
914914
if paint_soft_mask is self.INHERIT:
915915
return paint_soft_mask
916-
return cast(PaintSoftMask, paint_soft_mask)
916+
return cast(Union[PaintSoftMask, ImageSoftMask], paint_soft_mask)
917917

918918
@soft_mask.setter
919-
def soft_mask(self, value: Union[InheritType, "PaintSoftMask"]) -> None:
920-
if value is self.INHERIT or isinstance(value, PaintSoftMask):
919+
def soft_mask(
920+
self, value: Union[InheritType, "PaintSoftMask", "ImageSoftMask"]
921+
) -> None:
922+
if value is self.INHERIT or isinstance(value, (PaintSoftMask, ImageSoftMask)):
921923
return super().__setattr__(PDFStyleKeys.SOFT_MASK.value, value)
922-
raise TypeError(f"{value} isn't a PaintSoftMask or GraphicsStyle.INHERIT")
924+
raise TypeError(
925+
f"{value} isn't a PaintSoftMask, ImageSoftMask or GraphicsStyle.INHERIT"
926+
)
923927

924928
def serialize(self) -> Optional[Raw]:
925929
"""
@@ -3974,6 +3978,7 @@ def get_resource_dictionary(
39743978
self,
39753979
gfxstate_objs_per_name: dict[str, PDFObject],
39763980
pattern_objs_per_name: dict[str, PDFObject],
3981+
_img_objs_per_index: Optional[dict[int, PDFObject]] = None,
39773982
) -> str:
39783983
"""Build the resource dictionary for this soft mask, resolving GS & Pattern ids."""
39793984
resources_registered: dict[str, set[str]] = {}
@@ -4180,6 +4185,61 @@ def from_AB(
41804185
return sm
41814186

41824187

4188+
class ImageSoftMask:
4189+
"""
4190+
Soft mask backed by a grayscale image XObject.
4191+
4192+
The grayscale image is drawn into a transparency group; the group's luminance
4193+
is used as the mask values.
4194+
"""
4195+
4196+
__slots__ = ("image_index", "bbox", "matrix", "object_id")
4197+
4198+
def __init__(
4199+
self,
4200+
image_index: int,
4201+
bbox: tuple[float, float, float, float],
4202+
matrix: Transform,
4203+
) -> None:
4204+
self.image_index = image_index
4205+
self.bbox = bbox
4206+
self.matrix = matrix
4207+
self.object_id = 0
4208+
4209+
def serialize(self) -> str:
4210+
return f"<</S /Luminosity /G {self.object_id} 0 R>>"
4211+
4212+
def get_bounding_box(self) -> tuple[float, float, float, float]:
4213+
return self.bbox
4214+
4215+
def render(self, _resource_registry: "ResourceCatalog") -> str:
4216+
m = self.matrix
4217+
return (
4218+
"q "
4219+
f"{number_to_str(m.a)} {number_to_str(m.b)} "
4220+
f"{number_to_str(m.c)} {number_to_str(m.d)} "
4221+
f"{number_to_str(m.e)} {number_to_str(m.f)} cm "
4222+
f"/I{self.image_index} Do Q"
4223+
)
4224+
4225+
def get_resource_dictionary(
4226+
self,
4227+
_gfxstate_objs_per_name: dict[str, PDFObject],
4228+
_pattern_objs_per_name: dict[str, PDFObject],
4229+
img_objs_per_index: Optional[dict[int, PDFObject]] = None,
4230+
) -> str:
4231+
if img_objs_per_index is None or self.image_index not in img_objs_per_index:
4232+
raise RuntimeError("Soft mask image XObject not registered.")
4233+
img_obj = img_objs_per_index[self.image_index]
4234+
xobject = (
4235+
Name("XObject").serialize()
4236+
+ "<<"
4237+
+ f"{Name(f'I{self.image_index}').serialize()} {img_obj.id} 0 R"
4238+
+ ">>"
4239+
)
4240+
return "<<" + xobject + ">>"
4241+
4242+
41834243
def _iter_nodes(
41844244
node: GraphicsContext | PaintedPath,
41854245
) -> Iterator[GraphicsContext | PaintedPath]:

fpdf/font_type_3.py

Lines changed: 234 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
Protocol,
2626
Sequence,
2727
Union,
28+
cast,
2829
)
2930

3031
from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics
@@ -47,7 +48,9 @@
4748
ClippingPath,
4849
GlyphPathPen,
4950
GradientPaint,
51+
GraphicsStyle,
5052
GraphicsContext,
53+
ImageSoftMask,
5154
PaintBlendComposite,
5255
PaintComposite,
5356
PaintedPath,
@@ -62,6 +65,11 @@
6265
)
6366
from .pattern import SweepGradient, shape_linear_gradient, shape_radial_gradient
6467

68+
try:
69+
from PIL import Image
70+
except ImportError:
71+
Image = None # type: ignore[assignment]
72+
6573
if TYPE_CHECKING:
6674
from .fonts import TTFFont
6775
from .fpdf import FPDF
@@ -1009,6 +1017,230 @@ def load_glyph_image(self, glyph: Type3FontGlyph) -> None:
10091017
glyph.glyph_width = w
10101018

10111019

1020+
class EBDTBitmapFont(Type3Font):
1021+
"""Support for EBLC+EBDT bitmap fonts."""
1022+
1023+
def __init__(self, fpdf: "FPDF", base_font: "TTFFont"):
1024+
super().__init__(fpdf, base_font)
1025+
self._glyph_strike_indexes: dict[str, int] = {}
1026+
1027+
def _find_glyph_strike_index(self, glyph_name: str) -> Optional[int]:
1028+
strike_index = self._glyph_strike_indexes.get(glyph_name)
1029+
if strike_index is not None:
1030+
return strike_index
1031+
1032+
strikes_data = self.base_font.ttfont["EBDT"].strikeData
1033+
strikes = self.base_font.ttfont["EBLC"].strikes
1034+
strike_indexes = [
1035+
i for i, strike_data in enumerate(strikes_data) if glyph_name in strike_data
1036+
]
1037+
if not strike_indexes:
1038+
return None
1039+
1040+
target_ppem = self.get_target_ppem(self.base_font.biggest_size_pt)
1041+
bigger_or_equal = [
1042+
i for i in strike_indexes if strikes[i].bitmapSizeTable.ppemX >= target_ppem
1043+
]
1044+
if bigger_or_equal:
1045+
strike_index = min(bigger_or_equal, key=lambda i: self._ppem_x(strikes, i))
1046+
else:
1047+
strike_index = max(strike_indexes, key=lambda i: self._ppem_x(strikes, i))
1048+
self._glyph_strike_indexes[glyph_name] = strike_index
1049+
return strike_index
1050+
1051+
@staticmethod
1052+
def _ppem_x(strikes: Sequence[Any], strike_index: int) -> int:
1053+
return int(strikes[strike_index].bitmapSizeTable.ppemX)
1054+
1055+
def _get_glyph_metrics(
1056+
self, strike_index: int, glyph_name: str, bitmap_glyph: Any
1057+
) -> Any:
1058+
metrics = getattr(bitmap_glyph, "metrics", None)
1059+
if metrics is not None:
1060+
return metrics
1061+
for index_sub_table in (
1062+
self.base_font.ttfont["EBLC"].strikes[strike_index].indexSubTables
1063+
):
1064+
if glyph_name not in index_sub_table.names:
1065+
continue
1066+
metrics = getattr(index_sub_table, "metrics", None)
1067+
if metrics is not None:
1068+
return metrics
1069+
break
1070+
return None
1071+
1072+
@classmethod
1073+
def _decode_row(cls, packed_row: bytes, width: int, bit_depth: int) -> bytearray:
1074+
max_value = (1 << bit_depth) - 1
1075+
row_values = bytearray(width)
1076+
bit_index = 0
1077+
for pixel_index in range(width):
1078+
byte_index = bit_index // 8
1079+
bit_offset = bit_index % 8
1080+
bits_in_first_byte = min(bit_depth, 8 - bit_offset)
1081+
if bits_in_first_byte == bit_depth:
1082+
shift = 8 - bit_offset - bit_depth
1083+
value = (packed_row[byte_index] >> shift) & max_value
1084+
else:
1085+
first = packed_row[byte_index] & ((1 << bits_in_first_byte) - 1)
1086+
second_bits = bit_depth - bits_in_first_byte
1087+
second = packed_row[byte_index + 1] >> (8 - second_bits)
1088+
value = (first << second_bits) | second
1089+
row_values[pixel_index] = round(value * 255 / max_value)
1090+
bit_index += bit_depth
1091+
return row_values
1092+
1093+
@classmethod
1094+
def _bitmap_to_alpha(
1095+
cls,
1096+
bitmap_glyph: Any,
1097+
metrics: Any,
1098+
bit_depth: int,
1099+
) -> bytes:
1100+
alpha = bytearray(metrics.width * metrics.height)
1101+
for row_index in range(metrics.height):
1102+
packed_row = bitmap_glyph.getRow(
1103+
row_index, bitDepth=bit_depth, metrics=metrics
1104+
)
1105+
row = cls._decode_row(packed_row, metrics.width, bit_depth)
1106+
start = row_index * metrics.width
1107+
alpha[start : start + metrics.width] = row
1108+
return bytes(alpha)
1109+
1110+
def glyph_exists(self, glyph_name: str) -> bool:
1111+
return self._find_glyph_strike_index(glyph_name) is not None
1112+
1113+
def load_glyph_image(self, glyph: Type3FontGlyph) -> None:
1114+
if Image is None:
1115+
raise EnvironmentError(
1116+
f"{glyph.glyph_name}: Pillow is required to render EBDT glyphs."
1117+
)
1118+
1119+
strike_index = self._find_glyph_strike_index(glyph.glyph_name)
1120+
if strike_index is None:
1121+
raise ValueError(f"{glyph.glyph_name}: glyph not found in EBDT strikes.")
1122+
1123+
strike = self.base_font.ttfont["EBLC"].strikes[strike_index]
1124+
bit_depth = strike.bitmapSizeTable.bitDepth
1125+
if bit_depth not in (1, 2, 4, 8):
1126+
raise NotImplementedError(
1127+
f"{glyph.glyph_name}: unsupported EBDT bit depth {bit_depth}."
1128+
)
1129+
1130+
bitmap_glyph = self.base_font.ttfont["EBDT"].strikeData[strike_index][
1131+
glyph.glyph_name
1132+
]
1133+
metrics = self._get_glyph_metrics(strike_index, glyph.glyph_name, bitmap_glyph)
1134+
if metrics is None:
1135+
raise NotImplementedError(
1136+
f"{glyph.glyph_name}: EBDT glyph metrics could not be resolved."
1137+
)
1138+
if not hasattr(bitmap_glyph, "getRow"):
1139+
raise NotImplementedError(
1140+
f"{glyph.glyph_name}: unsupported EBDT glyph format ({type(bitmap_glyph).__name__})."
1141+
)
1142+
1143+
ppem_x = strike.bitmapSizeTable.ppemX or 1
1144+
ppem_y = strike.bitmapSizeTable.ppemY or ppem_x
1145+
if isinstance(metrics, SmallGlyphMetrics):
1146+
x_min = round(metrics.BearingX * self.upem / ppem_x)
1147+
y_min = round((metrics.BearingY - metrics.height) * self.upem / ppem_y)
1148+
x_max = round((metrics.BearingX + metrics.width) * self.upem / ppem_x)
1149+
y_max = round(metrics.BearingY * self.upem / ppem_y)
1150+
elif isinstance(metrics, BigGlyphMetrics):
1151+
x_min = round(metrics.horiBearingX * self.upem / ppem_x)
1152+
y_min = round((metrics.horiBearingY - metrics.height) * self.upem / ppem_y)
1153+
x_max = round((metrics.horiBearingX + metrics.width) * self.upem / ppem_x)
1154+
y_max = round(metrics.horiBearingY * self.upem / ppem_y)
1155+
else: # fallback scenario: use font bounding box
1156+
x_min = self.base_font.ttfont["head"].xMin
1157+
y_min = self.base_font.ttfont["head"].yMin
1158+
x_max = self.base_font.ttfont["head"].xMax
1159+
y_max = self.base_font.ttfont["head"].yMax
1160+
1161+
w = round(self.base_font.ttfont["hmtx"].metrics[glyph.glyph_name][0] + 0.001)
1162+
if bit_depth == 1:
1163+
alpha = self._bitmap_to_alpha(bitmap_glyph, metrics, bit_depth)
1164+
pixel_w = (x_max - x_min) / max(metrics.width, 1)
1165+
pixel_h = (y_max - y_min) / max(metrics.height, 1)
1166+
path_cmds: list[str] = []
1167+
for row_index in range(metrics.height):
1168+
row_start = row_index * metrics.width
1169+
row = alpha[row_start : row_start + metrics.width]
1170+
col = 0
1171+
while col < metrics.width:
1172+
if row[col] == 0:
1173+
col += 1
1174+
continue
1175+
start = col
1176+
while col < metrics.width and row[col] != 0:
1177+
col += 1
1178+
run_len = col - start
1179+
x = (x_min + start * pixel_w) * self.scale
1180+
y = (
1181+
y_min + (metrics.height - row_index - 1) * pixel_h
1182+
) * self.scale
1183+
w_run = (run_len * pixel_w) * self.scale
1184+
h_run = pixel_h * self.scale
1185+
path_cmds.append(f"{x:.3f} {y:.3f} {w_run:.3f} {h_run:.3f} re")
1186+
if path_cmds:
1187+
glyph.glyph = (
1188+
f"{round(w * self.scale)} 0 d0\n"
1189+
"q\n"
1190+
f"{' '.join(path_cmds)} f\n"
1191+
"Q"
1192+
)
1193+
else:
1194+
glyph.glyph = f"{round(w * self.scale)} 0 d0"
1195+
glyph.glyph_width = w
1196+
return
1197+
1198+
alpha = self._bitmap_to_alpha(bitmap_glyph, metrics, bit_depth)
1199+
alpha_image = Image.frombytes("L", (metrics.width, metrics.height), alpha)
1200+
bio = BytesIO()
1201+
alpha_image.save(bio, format="PNG")
1202+
bio.seek(0)
1203+
_, _, info = self.fpdf.preload_glyph_image(glyph_image_bytes=bio)
1204+
1205+
mask_matrix = Transform(
1206+
a=(x_max - x_min) * self.scale,
1207+
b=0,
1208+
c=0,
1209+
d=(y_max - y_min) * self.scale,
1210+
e=x_min * self.scale,
1211+
f=y_min * self.scale,
1212+
)
1213+
bbox = (
1214+
x_min * self.scale,
1215+
y_min * self.scale,
1216+
x_max * self.scale,
1217+
y_max * self.scale,
1218+
)
1219+
soft_mask = ImageSoftMask(cast(int, info["i"]), bbox, mask_matrix)
1220+
1221+
soft_mask.object_id = self.fpdf._resource_catalog.register_soft_mask( # pylint: disable=protected-access
1222+
soft_mask
1223+
)
1224+
style = GraphicsStyle()
1225+
style.soft_mask = soft_mask
1226+
gs_name = self.fpdf._resource_catalog.register_graphics_style( # pylint: disable=protected-access
1227+
style
1228+
)
1229+
if gs_name is None:
1230+
raise RuntimeError("Failed to register soft mask graphics state.")
1231+
self.graphics_style_used.add(str(gs_name))
1232+
1233+
glyph.glyph = (
1234+
f"{round(w * self.scale)} 0 d0\n"
1235+
"q\n"
1236+
f"/{gs_name} gs\n"
1237+
f"{x_min * self.scale} {y_min * self.scale} "
1238+
f"{(x_max - x_min) * self.scale} {(y_max - y_min) * self.scale} re f\n"
1239+
"Q"
1240+
)
1241+
glyph.glyph_width = w
1242+
1243+
10121244
class SBIXColorFont(Type3Font):
10131245
"""Support for SBIX bitmap color fonts."""
10141246

@@ -1077,9 +1309,8 @@ def get_color_font_object(
10771309
LOGGER.debug("Font %s is a CBLC+CBDT color font", base_font.name)
10781310
return CBDTColorFont(fpdf, base_font)
10791311
if "EBDT" in base_font.ttfont:
1080-
raise NotImplementedError(
1081-
f"{base_font.name} - EBLC+EBDT color font is not supported yet"
1082-
)
1312+
LOGGER.debug("Font %s is a EBLC+EBDT color font", base_font.name)
1313+
return EBDTBitmapFont(fpdf, base_font)
10831314
if "COLR" in base_font.ttfont:
10841315
if base_font.ttfont["COLR"].version == 0:
10851316
LOGGER.debug("Font %s is a COLRv0 color font", base_font.name)

0 commit comments

Comments
 (0)