8000 Merge pull request #29 from francozanardi/feat/text-shaping · francozanardi/pictex@a2305d6 · GitHub
[go: up one dir, main page]

Skip to content

Commit a2305d6

Browse files
Merge pull request #29 from francozanardi/feat/text-shaping
Support text shaping: kerning & ligatures
2 parents 7ce8f52 + 6e1719e commit a2305d6

File tree

170 files changed

+457
-863
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

170 files changed

+457
-863
lines changed

CHANGELOG.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [unreleased]
99

10-
### Fixed
11-
- Fixed SVG rendering with custom font files being embedded. It was not working because a wrong SVG tag was being used.
12-
1310
### Added
1411

1512
- Added comprehensive type annotations and mypy static type checking integration
1613
- **NamedColor Enum**: Exposed `NamedColor` enum class to improve developer experience when using colors. The enum provides easy access to all supported named colors with autocompletion and type safety.
14+
- **Text Shaping Support**: Added advanced text shaping capabilities including kerning, ligatures, and proper complex script rendering (Arabic, emoji sequences). Text now renders with correct character connections and spacing adjustments.
15+
16+
### Fixed
17+
18+
- Fixed SVG rendering with custom font files being embedded. It was not working because a wrong SVG tag was being used.
19+
- Fixed SVG font family normalization by removing spaces and commas from font identifiers to prevent rendering issues.
20+
- Fixed text wrapping when no width constraint is specified, avoiding unnecessary wrap calculations.
1721

1822
### Changed
1923

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A powerful Python library for creating complex visual compositions and beautiful
1515

1616
- **Component-Based Layout**: Compose complex visuals by nesting powerful layout primitives like `Row`, `Column`, and `Image`.
1717
- **Rich Styling**: Gradients, multiple shadows, borders with rounded corners, and text decorations.
18-
- **Advanced Typography**: Custom fonts, variable fonts, line height, and alignment.
18+
- **Advanced Typography**: Custom fonts, variable fonts, line height, alignment, and text shaping with kerning and ligatures.
1919
- **Automatic Font Fallback**: Seamlessly render emojis and multilingual text.
2020
- **Flexible Output**:
2121
- **Raster**: Save as PNG/JPEG/WebP, or convert to NumPy/Pillow.

docs/text.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,70 @@ canvas.render("Variable Font").save("variable_font.png")
8888

8989
`FontWeight` can be an enum member (e.g., `FontWeight.BOLD`) or an integer from 100 to 900.
9090

91+
## Text Shaping
92+
93+
`PicTex` includes advanced text shaping capabilities that improve text rendering quality through kerning, ligatures, and complex script support. This feature is automatically enabled and works behind the scenes to provide professional typography.
94+
95+
### Kerning
96+
97+
Kerning automatically adjusts the spacing between specific character pairs for better visual balance. For example, characters like "AV", "TY", or "Wo" will have optimized spacing.
98+
99+
### Ligatures
100+
101+
When using fonts that support ligatures, character sequences are automatically replaced with single, specially designed glyphs for improved readability.
102+
103+
```python
104+
from pictex import Canvas, NamedColor
105+
106+
(
107+
Canvas()
108+
.font_family("FiraCode-Medium.ttf")
109+
.font_size(100)
110+
.background_color(NamedColor.BEIGE)
111+
.color(NamedColor.BLUE)
112+
.render("-> != <= ==")
113+
.save("ligature.png")
114+
)
115+
```
116+
117+
![Ligature result](https://res.cloudinary.com/dlvnbnb9v/image/upload/v1759127040/docs-ligature_rwlxmg.png)
118+
119+
### Complex Script Support
120+
121+
Text shaping properly handles complex scripts like Arabic, where characters need to connect and change forms based on their position.
122+
123+
```python
124+
from pictex import Canvas, NamedColor
125+
126+
(
127+
Canvas()
128+
.font_size(100)
129+
.background_color(NamedColor.BEIGE)
130+
.color(NamedColor.DARKGREEN)
131+
.render("كتاب")
132+
.save("docs-arabic.png")
133+
)
134+
```
135+
136+
![Script support result](https://res.cloudinary.com/dlvnbnb9v/image/upload/v1759127040/docs-arabic_yffazq.png)
137+
138+
### Complex Emoji Sequences
139+
140+
Multi-part emoji sequences (like 👩‍🔬) are rendered as single glyphs instead of separate emoji characters.
141+
142+
```python
143+
from pictex import Canvas
144+
145+
(
146+
Canvas()
147+
.font_size(100)
148+
.render("👩‍🔬 🏳️‍🌈")
149+
.save("docs-emoji.png")
150+
)
151+
```
152+
153+
![Emoji sequences result](https://res.cloudinary.com/dlvnbnb9v/image/upload/v1759127041/docs-emoji_ekahio.png)
154+
91155
## Multi-line Text and Alignment
92156

93157
`PicTex` fully supports multi-line text using newline characters (`\n`). Additionally, text can automatically wrap when placed in containers with fixed widths.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ classifiers = [
3030

3131
dependencies = [
3232
"skia-python",
33-
"typing_extensions >=4.0; python_version < '3.11'"
33+
"typing_extensions >=4.0; python_version < '3.11'",
34+
"regex"
3435
]
3536

3637
[project.urls]
< 38BA div class="pt-3">

src/pictex/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
pictex: A Python library for creating complex visual compositions and beautifully styled images.
33
"""
44

5+
from . import __skia_init
6+
__skia_init.prime_skia_icu_engine()
7+
58
from .builders import Canvas, Text, Row, Column, Image, Element
69
from .models.public import *
710
from .bitmap_image import BitmapImage
@@ -31,4 +34,4 @@
3134

3235
"BitmapImage",
3336
"VectorImage",
34-
]
37+
]

src/pictex/__skia_init.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os
2+
import sys
3+
import contextlib
4+
5+
_skia_icu_primed = False
6+
def prime_skia_icu_engine():
7+
global _skia_icu_primed
8+
if _skia_icu_primed:
9+
return
10+
11+
try:
12+
import skia
13+
14+
with _suppress_stderr():
15+
skia.Unicode.ICU_Make()
16+
17+
except Exception:
18+
pass
19+
finally:
20+
_skia_icu_primed = True
21+
22+
@contextlib.contextmanager
23+
def _suppress_stderr():
24+
original_stderr_fd = sys.stderr.fileno()
25+
saved_stderr_fd = os.dup(original_stderr_fd)
26+
try:
27+
devnull_fd = os.open(os.devnull, os.O_WRONLY)
28+
os.dup2(devnull_fd, original_stderr_fd)
29+
yield
30+
finally:
31+
os.dup2(saved_stderr_fd, original_stderr_fd)
32+
475E os.close(saved_stderr_fd)
33+
if 'devnull_fd' in locals():
34+
os.close(devnull_fd)

src/pictex/models/internal/text.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from dataclasses import dataclass
2+
from typing import Optional
23
import skia
34

45
@dataclass
56
class TextRun:
67
"""Represents a segment of text that can be rendered with a single font."""
78
text: str
89
font: skia.Font
10+
blob: Optional[skia.TextBlob] = None
911
width: float = 0.0
1012

1113
@dataclass

src/pictex/nodes/text_node.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def _get_all_bounds(self) -> list[skia.Rect]:
128128

129129
def _set_width_constraint(self, width_constraint: Optional[int]) -> None:
130130
if width_constraint is None:
131-
self._text_wrap_width = self.content_width
131+
self._text_wrap_width = None
132132
return
133133

134134
wrap_width = width_constraint

src/pictex/painters/text.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,21 @@ def _add_shadows_to_paint(self, paint: skia.Paint) -> None:
3939
paint.setImageFilter(filter)
4040

4141
def _draw_text(self, canvas: skia.Canvas, paint: skia.Paint) -> None:
42-
primary_font = self._font_manager.get_primary_font()
43-
font_metrics = primary_font.getMetrics()
44-
current_y = self._text_bounds.top() - font_metrics.fAscent
42+
current_y = self._text_bounds.top()
4543
line_gap = self._style.line_height.get() * self._style.font_size.get()
4644
block_width = self._parent_bounds.width()
4745
outline_paint = self._build_outline_paint()
4846

4947
for line in self._lines:
5048
draw_x_start = self._text_bounds.x() + get_line_x_position(line.width, block_width, self._style.text_align.get())
5149
current_x = draw_x_start
52-
5350
for run in line.runs:
54-
canvas.drawString(run.text, current_x, current_y, run.font, paint)
51+
blob = run.blob
52+
C462 if not blob:
53+
blob = skia.TextBlob.MakeFromShapedText(run.text, run.font)
54+
canvas.drawTextBlob(blob, current_x, current_y, paint)
5555
if outline_paint:
56-
canvas.drawString(run.text, current_x, current_y, run.font, outline_paint)
56+
canvas.drawTextBlob(blob, current_x, current_y, outline_paint)
5757
current_x += run.width
5858

5959
current_y += line_gap

src/pictex/renderer/vector_image_processor.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def process(self, stream: skia.DynamicMemoryWStream, embed_fonts: bool, root: No
2020
svg = bytes(data).decode("utf-8")
2121
fonts = self._get_used_fonts(root)
2222
typefaces = self._map_to_file_typefaces(fonts, embed_fonts)
23+
svg = self._normalize_font_family_names(svg, typefaces)
2324
svg = self._fix_text_attributes(svg, typefaces)
2425
# svg = self._add_shadows(svg, root.computed_styles)
2526
svg = self._embed_fonts_in_svg(svg, typefaces, embed_fonts)
@@ -66,13 +67,7 @@ def _map_to_file_typefaces(self, fonts: list[skia.Font], should_warn_for_system_
6667

6768
def _embed_fonts_in_svg(self, svg: str, typefaces: list[TypefaceLoadingInfo], embed_fonts: bool) -> str:
6869
css = self._get_css_code_for_typefaces(typefaces, embed_fonts)
69-
defs = f"""
70-
<defs>
71-
<style type="text/css">
72-
{css}
73-
</style>
74-
</defs>
75-
"""
70+
defs = f"""<defs><style type="text/css">{css}</style></defs>""" if css else ""
7671

7772
svg_tag_pattern = re.compile(r"<svg[^>]*>")
7873
match = svg_tag_pattern.search(svg)
@@ -100,7 +95,7 @@ def _get_css_code_for_typefaces(self, typefaces: list[TypefaceLoadingInfo], embe
10095

10196
css = ""
10297
for typeface in typefaces:
103-
font_family = self._get_svg_family_name(typeface.typeface)
98+
font_family = self._get_svg_normalized_family_name(typeface.typeface)
10499
filepath = typeface.filepath
105100
if not filepath:
106101
continue
@@ -117,22 +112,32 @@ def _get_css_code_for_typefaces(self, typefaces: list[TypefaceLoadingInfo], embe
117112
font_format = format_map.get(file_extension, "truetype")
118113
src = f"data:font/{file_extension};base64,{encoded_font}') format('{font_format}"
119114

120-
css += f"""
121-
@font-face {{
115+
css += f"""@font-face {{
122116
font-family: '{font_family}';
123117
src: url('{src}');
124-
}}
125-
"""
118+
}}"""
126119

127120
return css
128121

129122
def _get_svg_family_name(self, typeface: skia.Typeface) -> str:
130123
family_names = list(map(lambda fn: fn[0], typeface.getFamilyNames()))
131124
return ", ".join(family_names)
132125

133-
def _add_prefix_to_font_families(self, svg: str, typefaces: list[TypefaceLoadingInfo]) -> str:
126+
def _get_svg_normalized_family_name(self, typeface: skia.Typeface) -> str:
127+
font_family = self._get_svg_family_name(typeface)
128+
return re.sub(r"\s+|,", "", font_family)
129+
130+
def _normalize_font_family_names(self, svg: str, typefaces: list[TypefaceLoadingInfo]) -> str:
134131
for typeface in typefaces:
135132
font_family = self._get_svg_family_name(typeface.typeface)
133+
normalized_font_family = self._get_svg_normalized_family_name(typeface.typeface)
134+
svg = svg.replace(f"'{font_family}'", f"'{normalized_font_family}'")
135+
svg = svg.replace(f'"{font_family}"', f'"{normalized_font_family}"')
136+
return svg
137+
138+
def _add_prefix_to_font_families(self, svg: str, typefaces: list[TypefaceLoadingInfo]) -> str:
139+
for typeface in typefaces:
140+
font_family = self._get_svg_normalized_family_name(typeface.typeface)
136141
svg = svg.replace(f"'{font_family}'", f"'pictex-{font_family}'")
137142
svg = svg.replace(f'"{font_family}"', f'"pictex-{font_family}"')
138143
return svg
@@ -154,7 +159,7 @@ def _fix_text_attributes(self, svg: str, typefaces: list[TypefaceLoadingInfo]) -
154159
elements = root.findall(".//{http://www.w3.org/2000/svg}text")
155160
for tf in typefaces:
156161
is_variable_font = utils.is_variable_font(tf.typeface)
157-
font_family = self._get_svg_family_name(tf.typeface)
162+
font_family = self._get_svg_normalized_family_name(tf.typeface)
158163
for text_elem in elements:
159164
if text_elem.attrib.get("font-family", None) != font_family:
160165
continue

0 commit comments

Comments
 (0)
0