Skip to content
This repository has been archived by the owner on Sep 3, 2024. It is now read-only.

Commit

Permalink
Layout: Improve font for non-pixeled fonts (#210)
Browse files Browse the repository at this point in the history
Fixes scaling to bbox
Add distributed points for fontlayout
Use freetype instead of fonttools

(Rebased version of #208)
  • Loading branch information
IoannisP-ITENG authored Jul 6, 2024
1 parent 15c5759 commit 9c89b29
Showing 5 changed files with 421 additions and 180 deletions.
18 changes: 17 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ black = "^24.4.2"
typing-extensions = "^4.6.3"
easyeda2kicad = "^0.8.0"
shapely = "^2.0.1"
fonttools = "^4.53.0"
freetype-py = "^2.4.0"
kicadcliwrapper = "^1.0.0"

[tool.poetry.group.dev.dependencies]
126 changes: 23 additions & 103 deletions src/faebryk/exporters/pcb/layout/font.py
Original file line number Diff line number Diff line change
@@ -10,7 +10,8 @@
has_pcb_position_defined_relative_to_parent,
)
from faebryk.libs.font import Font
from faebryk.libs.geometry.basic import fill_poly_with_nodes_on_grid, transform_polygons
from faebryk.libs.geometry.basic import get_distributed_points_in_polygon
from rich.progress import track

logger = logging.getLogger(__name__)

@@ -19,118 +20,39 @@ class FontLayout(Layout):
def __init__(
self,
font: Font,
font_size: float,
text: str,
resolution: tuple[float, float],
density: float,
bbox: tuple[float, float] | None = None,
char_dimensions: tuple[float, float] | None = None,
kerning: float = 1,
scale_to_fit: bool = False,
) -> None:
"""
Map a text string with a given font to a grid with a given resolution and map
a node on each node of the grid that is inside the string.
:param ttf: Path to the ttf font file
:param text: Text to render
:param char_dimensions: Bounding box of a single character (x, y) in mm
:param resolution: Resolution (x, y) in nodes/mm
:param kerning: Distance between characters, relative to the resolution of a
single character in mm
Create a layout that distributes nodes in a font
:param font: The font to use
:param font_size: The font size to use in points
:param text: The text to distribute
:param density: The density of the distribution in nodes/point
:param bbox: The bounding box to distribute the nodes in
:param scale_to_fit: Whether to scale the font to fit the bounding box
"""
super().__init__()

self.font = font

assert bbox or char_dimensions, "Either bbox or char_dimensions must be given"
assert len(text) > 0, "Text must not be empty"

self.poly_glyphs = [
# poly for letter in text for poly in self.font.letter_to_polygons(letter)
self.font.letter_to_polygons(letter)
for letter in text
]

# Debugging
if logger.isEnabledFor(logging.DEBUG):
for i, polys in enumerate(self.poly_glyphs):
logger.debug(f"Found {len(polys)} polygons for letter {text[i]}")
for p in polys:
logger.debug(f"Polygon with {len(p.exterior.coords)} vertices")
logger.debug(f"Coords: {list(p.exterior.coords)}")

char_dim_max = self.font.get_max_glyph_dimensions(text)

logger.debug(f"Max character dimension in text '{text}': {char_dim_max}")

offset = (0, 0)
scale = (1, 1)

if char_dimensions is None and bbox is not None:
char_width = (bbox[0] - (len(text) - 1) * kerning) / len(text)
char_height = bbox[1]

s = min(
char_width / (char_dim_max[2] - char_dim_max[0]),
char_height / (char_dim_max[3] - char_dim_max[1]),
)
scale = (s, s)
else:
assert char_dimensions is not None
offset = (-char_dim_max[0], -char_dim_max[1])
scale = (
char_dimensions[0] / (char_dim_max[2] - char_dim_max[0]),
char_dimensions[1] / (char_dim_max[3] - char_dim_max[1]),
)

logger.debug(f"Offset: {offset}")
logger.debug(f"Scale: {scale}")

self.poly_glyphs = transform_polygons(
self.poly_glyphs,
offset,
scale,
logger.info(f"Creating font layout for text: {text}")
polys = font.string_to_polygons(
text, font_size, bbox=bbox, scale_to_fit=scale_to_fit
)

# set grid offset to half a grid pitch to center the nodes
grid_offset = (1 / resolution[0] / 2, 1 / resolution[1] / 2)
grid_pitch = (1 / resolution[0], 1 / resolution[1])

logger.debug(f"Grid pitch: {grid_pitch}")
logger.debug(f"Grid offset: {grid_offset}")

self.coords = []

for i, polys in enumerate(self.poly_glyphs):
# Debugging
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f"Processing letter {text[i]}")
logger.debug(f"Found {len(polys)} polygons for letter {text[i]}")
for p in polys:
logger.debug(f"Polygon with {len(p.exterior.coords)} vertices")
logger.debug(f"Coords: {list(p.exterior.coords)}")

glyph_nodes = fill_poly_with_nodes_on_grid(
polys=polys,
grid_pitch=grid_pitch,
grid_offset=grid_offset,
)
logger.info(f"Finding points in polygons with density: {density}")
nodes = []
for p in track(polys, description="Finding points in polygons"):
nodes.extend(get_distributed_points_in_polygon(polygon=p, density=density))

# apply character offset in string + kerning
char_offset_x = (
max([0] + [c[0] for c in self.coords]) + 1 / resolution[0] + kerning
)
for node in glyph_nodes:
self.coords.append(
(
node.x + char_offset_x,
node.y,
)
)
logger.debug(f"Found {len(glyph_nodes)} nodes for letter {text[i]}")
logger.info(f"Creating {len(nodes)} nodes in polygons")

# Move down because the font has the origin in the bottom left while KiCad has
# it in the top left
char_offset_y = -max([0] + [c[1] for c in self.coords])
self.coords = [(c[0], c[1] + char_offset_y) for c in self.coords]
self.coords = [(n.x, n.y) for n in nodes]

def get_count(self) -> int:
"""
@@ -153,9 +75,7 @@ def apply(self, *nodes_to_distribute: Node) -> None:
node.add_trait(
has_pcb_position_defined_relative_to_parent(
(
coord[0],
# TODO mirrored Y-axis bug
-coord[1],
*coord,
0,
has_pcb_position.layer_type.NONE,
)
164 changes: 103 additions & 61 deletions src/faebryk/libs/font.py
Original file line number Diff line number Diff line change
@@ -2,13 +2,14 @@
# SPDX-License-Identifier: MIT

import logging
from math import inf
from pathlib import Path

from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.recordingPen import RecordingPen
from fontTools.ttLib import TTFont
from shapely import Polygon
import freetype
from faebryk.libs.geometry.basic import (
flatten_polygons,
transform_polygon,
)
from shapely import Point, Polygon

logger = logging.getLogger(__name__)

@@ -18,72 +19,113 @@ def __init__(self, ttf: Path):
super().__init__()

self.path = ttf
self.font = TTFont(ttf)

def get_max_glyph_dimensions(
self, text: str | None = None
) -> tuple[float, float, float, float]:
def string_to_polygons(
self,
string: str,
font_size: float,
bbox: tuple[float, float] | None = None,
wrap: bool = False,
scale_to_fit: bool = False,
) -> list[Polygon]:
"""
Get the maximum dimensions of all glyphs combined in a font
Render the polygons of a string from a ttf font file
:param ttf_path: Path to the ttf font file
:param string: The string to extract
:param font_size: The font size in points
:param bbox: The bounding box to fit the text in, in points
:param wrap: Wrap the text to fit the bounding box
:param scale_to_fit: Scale the text to fit the bounding box
:return: A list of polygons that represent the string
"""
glyphset = self.font.getGlyphSet()
bp = BoundsPen(glyphset)

max_dim = (inf, inf, -inf, -inf)
for glyph_name in glyphset.keys() if text is None else set(text):
glyphset[glyph_name].draw(bp)
if wrap and not bbox:
raise ValueError("Bounding box must be given when wrapping text")

if not bp.bounds:
continue
if scale_to_fit and not bbox:
raise ValueError("Bounding box must be given when fitting text")

max_dim = (
min(max_dim[0], bp.bounds[0]),
min(max_dim[1], bp.bounds[1]),
max(max_dim[2], bp.bounds[2]),
max(max_dim[3], bp.bounds[3]),
)
if wrap and scale_to_fit:
raise NotImplementedError("Cannot wrap and scale to fit at the same time")

return max_dim
# TODO: use bezier control points in outline.tags

def letter_to_polygons(self, letter: str) -> list[Polygon]:
"""
Extract the polygons of a single letter from a ttf font file
face = freetype.Face(str(self.path))
polygons = []
offset = Point(0, 0)

:param ttf_path: Path to the ttf font file
:param letter: The letter to extract
:return: A list of polygons that represent the letter
"""
font = self.font
cmap = font.getBestCmap()
glyph_set = font.getGlyphSet()
glyph = glyph_set[cmap[ord(letter)]]
contours = Font.extract_contours(glyph)
if scale_to_fit:
font_size = 1

polys = []
for contour in contours:
polys.append(Polygon(contour))
text_size = Point(0, 0)

return polys
scale = font_size / face.units_per_EM
for char in string:
face.load_char(char)

@staticmethod
def extract_contours(glyph) -> list[list[tuple[float, float]]]:
"""
Extract the contours of a glyph
if bbox and not scale_to_fit:
if offset.x + face.glyph.advance.x > bbox[0] / scale:
if not wrap:
break
offset = Point(0, offset.y + face.glyph.advance.y)
if offset.y > bbox[1] / scale:
break

:param glyph: The glyph to extract the contours from
:return: A list of contours, each represented by a list of coordinates
"""
contours = []
current_contour = []
pen = RecordingPen()
glyph.draw(pen)
trace = pen.value
for flag, coords in trace:
if flag == "lineTo": # On-curve point
current_contour.append(coords[0])
if flag == "moveTo": # Move to a new contour
current_contour = [coords[0]]
if flag == "closePath": # Close the current contour
current_contour.append(current_contour[0])
contours.append(current_contour)
return contours
points = face.glyph.outline.points
contours = face.glyph.outline.contours

start = 0

for contour in contours:
contour_points = [Point(p) for p in points[start : contour + 1]]
contour_points.append(contour_points[0])
start = contour + 1
contour_points = [
Point(p.x + offset.x, p.y + offset.y) for p in contour_points
]
polygons.append(Polygon(contour_points))

offset = Point(offset.x + face.glyph.advance.x, offset.y)

if not wrap or not bbox:
continue

if offset.x > bbox[0]:
offset = Point(0, offset.y + face.glyph.advance.y)
if offset.y > bbox[1]:
break

bounds = [p.bounds for p in polygons]
min_x, min_y, max_x, max_y = (
min(b[0] for b in bounds),
min(b[1] for b in bounds),
max(b[2] for b in bounds),
max(b[3] for b in bounds),
)
offset = Point(
-min_x,
-min_y,
)

if scale_to_fit and bbox:
scale = min(bbox[0] / (max_x - min_x), bbox[1] / (max_y - min_y))

logger.debug(f"Text size: {text_size}")
logger.debug(f"Offset: {offset}")
logger.debug(f"Scale: {scale}")

polygons = flatten_polygons(polygons)
polygons = [
transform_polygon(p, scale=scale, offset=(offset.x, offset.y))
for p in polygons
]

# Invert the y-axis
max_y = max(p.bounds[3] for p in polygons)
polygons = [
Polygon([(p[0], -p[1] + max_y) for p in polygon.exterior.coords])
for polygon in polygons
]

return polygons
Loading

0 comments on commit 9c89b29

Please sign in to comment.