|
25 | 25 | Protocol, |
26 | 26 | Sequence, |
27 | 27 | Union, |
| 28 | + cast, |
28 | 29 | ) |
29 | 30 |
|
30 | 31 | from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics |
|
47 | 48 | ClippingPath, |
48 | 49 | GlyphPathPen, |
49 | 50 | GradientPaint, |
| 51 | + GraphicsStyle, |
50 | 52 | GraphicsContext, |
| 53 | + ImageSoftMask, |
51 | 54 | PaintBlendComposite, |
52 | 55 | PaintComposite, |
53 | 56 | PaintedPath, |
|
62 | 65 | ) |
63 | 66 | from .pattern import SweepGradient, shape_linear_gradient, shape_radial_gradient |
64 | 67 |
|
| 68 | +try: |
| 69 | + from PIL import Image |
| 70 | +except ImportError: |
| 71 | + Image = None # type: ignore[assignment] |
| 72 | + |
65 | 73 | if TYPE_CHECKING: |
66 | 74 | from .fonts import TTFFont |
67 | 75 | from .fpdf import FPDF |
@@ -1009,6 +1017,230 @@ def load_glyph_image(self, glyph: Type3FontGlyph) -> None: |
1009 | 1017 | glyph.glyph_width = w |
1010 | 1018 |
|
1011 | 1019 |
|
| 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 | + |
1012 | 1244 | class SBIXColorFont(Type3Font): |
1013 | 1245 | """Support for SBIX bitmap color fonts.""" |
1014 | 1246 |
|
@@ -1077,9 +1309,8 @@ def get_color_font_object( |
1077 | 1309 | LOGGER.debug("Font %s is a CBLC+CBDT color font", base_font.name) |
1078 | 1310 | return CBDTColorFont(fpdf, base_font) |
1079 | 1311 | 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) |
1083 | 1314 | if "COLR" in base_font.ttfont: |
1084 | 1315 | if base_font.ttfont["COLR"].version == 0: |
1085 | 1316 | LOGGER.debug("Font %s is a COLRv0 color font", base_font.name) |
|
0 commit comments