Merge pull request #1169 from jwagenet/single_line
Some checks failed
benchmarks / benchmarks (macos-14, 3.12) (push) Has been cancelled
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Has been cancelled
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Has been cancelled
benchmarks / benchmarks (windows-latest, 3.12) (push) Has been cancelled
Upload coverage reports to Codecov / run (push) Has been cancelled
pylint / lint (3.10) (push) Has been cancelled
Wheel building and publishing / Build wheel on ubuntu-latest (push) Has been cancelled
tests / tests (macos-14, 3.10) (push) Has been cancelled
tests / tests (macos-14, 3.14) (push) Has been cancelled
tests / tests (macos-15-intel, 3.10) (push) Has been cancelled
tests / tests (macos-15-intel, 3.14) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.10) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.14) (push) Has been cancelled
tests / tests (windows-latest, 3.10) (push) Has been cancelled
tests / tests (windows-latest, 3.14) (push) Has been cancelled
Run type checking / typecheck (3.10) (push) Has been cancelled
Run type checking / typecheck (3.14) (push) Has been cancelled
Wheel building and publishing / upload_pypi (push) Has been cancelled

Feature: Support single line fonts and add FontManager Class
This commit is contained in:
Roger Maitland 2026-02-10 11:10:04 -05:00 committed by GitHub
commit cb155f79d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 967 additions and 181 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View file

@ -528,6 +528,13 @@ Reference
.. autoclass:: Torus
.. autoclass:: Wedge
Text
----
.. include:: objects/text.rst
Custom Objects
--------------

View file

@ -0,0 +1,17 @@
from build123d import Text, Pos, Compound, TextAlign, Align, Location, RadiusArc
from tcv_screenshots import save_model
text = "The quick brown fox"
save_model(Text(text, 10), "text", {"reset_camera": "top"})
path = RadiusArc((-50, 0), (50, 0), 100)
save_model([path, Text(text, 10, path=path, position_on_path=.5, text_align=(TextAlign.CENTER, TextAlign.BOTTOM))], "path", {"reset_camera": "top"})
save_model([Pos(Y=10) * Text(text, 10, "singleline"), Text(text, 10, "singleline", single_line_width=1)], "outline", {"reset_camera": "top"})
save_model(Compound.make_text(text, 10, "singleline"), "singleline", {"reset_camera": "top"})
text = "The quick brown\nfox jumped over\nthe lazy dog."
save_model([Location(), Text(text, 2, text_align=(TextAlign.LEFT, TextAlign.TOPFIRSTLINE))], "text_align", {"reset_camera": "top"})
save_model([Location(), Text(text, 2, align=(Align.MIN, Align.MIN))], "align", {"reset_camera": "top"})
t = Text("The", 10, "Source Sans 3 Black")
save_model([(Pos(Y=10) * t).wires(), t], "missing_glyph", {"reset_camera": "top"})

358
docs/objects/text.rst Normal file
View file

@ -0,0 +1,358 @@
Create Text Object
^^^^^^^^^^^^^^^^^^
Create text object or add to ``BuildSketch`` using :class:`~objects_sketch.Text`:
.. image:: /assets/objects/text.png
:width: 80%
:align: center
|
.. code-block:: build123d
text = "The quick brown fox jumped over the lazy dog."
Text(text, 10)
Specify font and style. Fonts have up to 4 font styles: ``REGULAR``, ``BOLD``,
``ITALIC``, ``BOLDITALIC``. All fonts can use ``ITALIC`` even if only
``REGULAR`` is defined.
.. code-block:: build123d
Text(text, 10, "Arial", font_style=FontStyle.BOLD)
Find available fonts on system and available styles:
.. code-block:: build123d
from pprint import pprint
pprint(available_fonts())
.. code-block:: text
[
...
Font(name='Arial', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
Font(name='Arial Black', styles=('REGULAR',)),
Font(name='Arial Narrow', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
Font(name='Arial Rounded MT Bold', styles=('REGULAR',)),
...
]
Font faces like ``"Arial Black"`` or ``"Arial Narrow"`` must be specified
by name rather than ``FontStyle``:
.. code-block:: build123d
Text(text, 10, "Arial Black")
Specify a font file directly by filename:
.. code-block:: build123d
Text(text, 10, font_path="DejaVuSans.ttf")
Fonts added via ``font_path`` persist in the font list:
.. code-block:: build123d
Text(text, 10, font_path="SourceSans3-VariableFont_wght.ttf")
pprint([f.name for f in available_fonts() if "Source Sans" in f.name])
Text(text, 10, "Source Sans 3 Medium")
.. code-block:: text
['Source Sans 3',
'Source Sans 3 Black',
'Source Sans 3 ExtraBold',
'Source Sans 3 ExtraLight',
...]
Add a font file to ``FontManager`` if a font is reused in the script or
contains multiple font faces:
.. code-block:: build123d
new_font_faces = FontManager().register_font("Roboto-VariableFont_wdth,wght.ttf")
pprint(new_font_faces)
Text(text, 10, "Roboto")
Text(text, 10, "Roboto Black")
.. code-block:: text
['Roboto Thin',
'Roboto ExtraLight',
'Roboto Light',
'Roboto',
...]
Placement
^^^^^^^^^
Multiline text has two methods of alignment.
``text_align`` aligns the text relative to its ``Location``:
.. image:: /assets/objects/text_align.png
:width: 80%
:align: center
|
.. code-block:: build123d
Text(text, 10, text_align=(TextAlign.LEFT, TextAlign.TOPFIRSTLINE))
``align`` aligns the object bounding box relative to its ``Location`` *after*
text alignment:
.. image:: /assets/objects/align.png
:width: 80%
:align: center
|
.. code-block:: build123d
text = "The quick brown\nfox jumped over\nthe lazy dog."
Text(text, 10, align=(Align.MIN, Align.MIN))
Place text along an ``Edge`` or ``Wire`` with ``path`` and ``position_on_path``:
.. image:: /assets/objects/path.png
:width: 80%
:align: center
|
.. code-block:: build123d
text = "The quick brown fox"
path = RadiusArc((-50, 0), (50, 0), 100)
Text(
text,
10,
path=path,
position_on_path=.5,
text_align=(TextAlign.CENTER, TextAlign.BOTTOM)
)
Single Line Fonts
^^^^^^^^^^^^^^^^^
``"singleline"`` is a special font referencing ``Relief SingleLine CAD``.
Glyphs are represented as single lines rather than filled faces.
``Text`` creates an outlined face by default. The outline width is controlled
by ``single_line_width``. This operation is slow with many glyphs.
.. image:: /assets/objects/outline.png
:width: 80%
:align: center
|
.. code-block:: build123d
Text(text, 10, "singleline")
Text(text, 10, "singleline", single_line_width=1)
Use ``Compound.make_text()`` to create *unoutlined* single-line text.
Useful for routing, engraving, or drawing label paths.
.. image:: /assets/objects/singleline.png
:width: 80%
:align: center
|
.. code-block:: build123d
Compound.make_text(text, 10, "singleline")
Common Issues
^^^^^^^^^^^^^
Missing Glyphs or Invalid Geometry
==================================
Modern variable-width fonts often contain glyphs with overlapping stroke
outlines, which produce invalid geometry. ``ocp_vscode`` ignores invalid
faces.
.. image:: /assets/objects/missing_glyph.png
:align: center
|
.. code-block:: build123d
Text("The", 10, "Source Sans 3 Black")
FileNotFoundError
=================
Ensure relative ``font_path`` specifications are relative to the *current
working directory*.
.. Working With Text
.. #################
.. Create text object or add to ``BuildSketch``:
.. .. code-block:: build123d
.. text = "The quick brown fox jumped over the lazy dog."
.. Text(text, 10)
.. Specify font and style. Fonts have up to 4 font styles: ``REGULAR``, ``BOLD``, ``ITALIC``, ``BOLDITALIC``.
.. All fonts can use ``ITALIC`` even if only ``REGULAR`` is defined.
.. .. code-block:: build123d
.. Text(text, 10, "Arial", font_style=FontStyle.BOLD)
.. Find available fonts on system and available styles:
.. .. code-block:: build123d
.. from pprint import pprint
.. pprint(available_fonts())
.. .. code-block:: text
.. [
.. ...
.. Font(name='Arial', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
.. Font(name='Arial Black', styles=('REGULAR',)),
.. Font(name='Arial Narrow', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
.. Font(name='Arial Rounded MT Bold', styles=('REGULAR',)),
.. ...
.. ]
.. Font faces like ``"Arial Black"`` or ``"Arial Narrow"`` need to be specified by name rather than ``FontStyle``:
.. .. code-block:: build123d
.. Text(text, 10, "Arial Black")
.. .. code-block:: build123d
.. Text(text, 10, font_path="DejaVuSans.ttf")
.. .. code-block:: build123d
.. Text(text, 10, font_path="SourceSans3-VariableFont_wght.ttf")
.. pprint([f.name for f in available_fonts() if "Source Sans" in f.name])
.. Text(text, 10, "Source Sans 3 Medium")
.. .. code-block:: text
.. ['Source Sans 3',
.. 'Source Sans 3 Black',
.. 'Source Sans 3 ExtraBold',
.. 'Source Sans 3 ExtraLight',
.. ...]
.. .. code-block:: build123d
.. new_font_faces = FontManager().register_font("Roboto-VariableFont_wdth,wght.ttf")
.. pprint(new_font_faces)
.. Text(text, 10, "Roboto")
.. Text(text, 10, "Roboto Black")
.. .. code-block:: text
.. ['Roboto Thin',
.. 'Roboto ExtraLight',
.. 'Roboto Light',
.. 'Roboto',
.. ...]
.. Placement
.. #########
.. Multiline text has two methods of alignment:
.. ``text_align`` aligns the text relative to its ``Location``:
.. .. code-block:: build123d
.. Text(text, 10, text_align=(TextAlign.LEFT, TextAlign.TOPFIRSTLINE))
.. ``align`` aligns the object bounding box relative to its ``Location`` *after* text alignment:
.. .. code-block:: build123d
.. text = "The quick brown\nfox jumped over\nthe lazy dog."
.. Text(text, 10, align=(Align.MIN, Align.MIN))
.. Place text along an ``Edge`` or ``Wire`` with ``path`` and ``position_on_path``:
.. .. code-block:: build123d
.. text = "The quick brown fox"
.. path = RadiusArc((-50, 0), (50, 0), 100)
.. Text(
.. text,
.. 10,
.. path=path,
.. position_on_path=.5,
.. text_align=(TextAlign.CENTER, TextAlign.BOTTOM)
.. )
.. Single Line Fonts
.. #################
.. ``"singleline"`` is a special font referencing ``Relief SingleLine CAD``.
.. Glyphs are represented as single lines rather than filled faces.
.. ``Text`` creates an outlined face by default.
.. The outline width is controlled by ``single_line_width``.
.. This operation is slow with many glyphs.
.. .. code-block:: build123d
.. Text(text, 10, "singleline")
.. Text(text, 10, "singleline", single_line_width=1)
.. Use ``Compound.make_text`` to create *unoutlined* single-line text.
.. Useful for routing, engraving, or drawing label path.
.. .. code-block:: build123d
.. Compound.make_text(text, 10, "singleline")
.. Common Issues
.. #############
.. Missing Glyphs or Invalid Geometry
.. ##################################
.. Modern variable-width fonts often contain glyphs with overlapping stroke outlines, which produce
.. invalid geometry. ``ocp_vscode`` ignores invalid faces.
.. .. code-block:: build123d
.. Text("The", 10, "Source Sans 3 Black")
.. FileNotFoundError
.. #################
.. Ensure relative ``font_path`` specifications are relative to the *current working directory*

View file

@ -21,7 +21,7 @@ from build123d.topology import *
from build123d.drafting import *
from build123d.persistence import modify_copyreg
from build123d.exporters3d import *
from build123d.utils import available_fonts
from build123d.text import available_fonts, FontManager
from .version import version as __version__
@ -173,6 +173,7 @@ __all__ = [
"CylindricalJoint",
"BallJoint",
"DraftAngleError",
"FontManager",
# Exporter classes
"Export2D",
"ExportDXF",

View file

@ -0,0 +1,44 @@
Copyright 2022 The Relief SingleLine Project Authors (https://github.com/isdat-type/Relief-SingleLine)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -720,13 +720,13 @@ class BallJoint(Joint):
circle_y,
circle_z,
Compound.make_text(
"X", radius / 5, align=(Align.CENTER, Align.CENTER)
"X", radius / 5, "singleline", align=(Align.CENTER, Align.CENTER)
).locate(circle_x.location_at(0.125) * Rotation(90, 0, 0)),
Compound.make_text(
"Y", radius / 5, align=(Align.CENTER, Align.CENTER)
"Y", radius / 5, "singleline", align=(Align.CENTER, Align.CENTER)
).locate(circle_y.location_at(0.625) * Rotation(90, 0, 0)),
Compound.make_text(
"Z", radius / 5, align=(Align.CENTER, Align.CENTER)
"Z", radius / 5, "singleline", align=(Align.CENTER, Align.CENTER)
).locate(circle_z.location_at(0.125) * Rotation(90, 0, 0)),
]
).move(self.location)

View file

@ -581,6 +581,8 @@ class Text(BaseSketchObject):
path (Edge | Wire, optional): path for text to follow. Defaults to None
position_on_path (float, optional): the relative location on path to position
the text, values must be between 0.0 and 1.0. Defaults to 0.0
single_line_width (float, optional): width of outlined single line font.
Defaults to 4% of font_size
rotation (float, optional): angle to rotate object. Defaults to 0
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
@ -599,12 +601,22 @@ class Text(BaseSketchObject):
align: Align | tuple[Align, Align] | None = None,
path: Edge | Wire | None = None,
position_on_path: float = 0.0,
single_line_width: float | None = None,
rotation: float = 0.0,
mode: Mode = Mode.ADD,
):
context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self)
if single_line_width is None:
# Ensure line width is passed for single line fonts to convert to faces
# Default is 4% of font_size
single_line_width = 0.04 * font_size
elif single_line_width <= 0.0:
raise ValueError(
f"single_line_width ({single_line_width}) must be greater than 0"
)
self.txt = txt
self.font_size = font_size
self.font = font
@ -614,6 +626,7 @@ class Text(BaseSketchObject):
self.align = align
self.text_path = path
self.position_on_path = position_on_path
self.single_line_width = single_line_width
self.rotation = rotation
self.mode = mode
@ -627,6 +640,7 @@ class Text(BaseSketchObject):
align=align,
position_on_path=position_on_path,
text_path=path,
single_line_width=single_line_width,
)
super().__init__(text_string, rotation, None, mode)

267
src/build123d/text.py Normal file
View file

@ -0,0 +1,267 @@
"""
build123d font and text objects
name: text.py
by: jwagenet
date: July 28th 2025
desc:
This python module contains font and text objects.
"""
import glob
import os
import platform
import sys
from dataclasses import dataclass
from fontTools.ttLib import TTFont, ttCollection # type:ignore
from OCP.Font import (
Font_FA_Bold,
Font_FA_BoldItalic,
Font_FA_Italic,
Font_FA_Regular,
Font_FontMgr,
Font_SystemFont,
)
from OCP.TCollection import TCollection_AsciiString
from OCP.TColStd import TColStd_SequenceOfHAsciiString
from build123d.build_enums import FontStyle
FONT_ASPECT = {
FontStyle.REGULAR: Font_FA_Regular,
FontStyle.BOLD: Font_FA_Bold,
FontStyle.ITALIC: Font_FA_Italic,
FontStyle.BOLDITALIC: Font_FA_BoldItalic,
}
@dataclass(frozen=True)
class FontInfo:
"""Representation for registered font.
Not immediately compatible with Font_SystemFont, which only contains a single
style/aspect.
"""
name: str
styles: tuple[FontStyle, ...]
def __repr__(self) -> str:
style_names = tuple(s.name for s in self.styles)
return f"Font(name={self.name!r}, styles={style_names})"
class FontManager:
"""Wrap OCP Font_FontMgr"""
bundled_path = "data/fonts"
bundled_fonts = [
(
"Relief SingleLine CAD",
"reliefsingleline/ReliefSingleLineCAD-Regular.ttf",
True,
)
]
def __init__(self):
"""Initialize FontManager
Bundled fonts are added to global OCP instance if they haven't already
"""
# Should clarify if this is necessary
if sys.platform.startswith("linux"):
os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf"
os.environ["FONTCONFIG_PATH"] = "/etc/fonts/"
self.manager = Font_FontMgr.GetInstance_s()
# Check if OCP manager is already initialized. "singleline" alias is canary
aliases = TColStd_SequenceOfHAsciiString()
self.manager.GetAllAliases(aliases)
aliases = [aliases.Value(i).ToCString() for i in range(1, aliases.Length() + 1)]
if "singleline" not in aliases:
if platform.system() == "Windows": # pragma: no cover
# OCCT doesnt add user fonts on Windows
self.register_system_fonts()
working_path = os.path.dirname(os.path.abspath(__file__))
for font in self.bundled_fonts:
font_path = os.path.normpath(
os.path.join(working_path, self.bundled_path, font[1])
)
self.register_font(font_path, single_stroke=font[2])
self.manager.AddFontAlias(
TCollection_AsciiString("singleline"),
TCollection_AsciiString("Relief SingleLine CAD"),
)
def available_fonts(self) -> list[FontInfo]:
"""Get list of available fonts by name and available styles (also called aspects)"""
font_aspects = {
"REGULAR": Font_FA_Regular,
"BOLD": Font_FA_Bold,
"BOLDITALIC": Font_FA_BoldItalic,
"ITALIC": Font_FA_Italic,
}
font_list = []
for f in self.manager.GetAvailableFonts():
avail_aspects = tuple(
FontStyle[n] for n, a in font_aspects.items() if f.HasFontAspect(a)
)
font_list.append(FontInfo(f.FontName().ToCString(), avail_aspects))
font_list.sort(key=lambda x: x.name)
return font_list
def check_font(self, path: str) -> Font_SystemFont | None:
"""Check if font exists at path and return system font"""
return self.manager.CheckFont(path)
def find_font(self, name: str, style: FontStyle) -> Font_SystemFont:
"""Find font in FontManager library by name and style"""
return self.manager.FindFont(TCollection_AsciiString(name), FONT_ASPECT[style])
def register_font(
self, path: str, override: bool = False, single_stroke=False
) -> list[str]:
"""Register all font faces in a font file and return font face names."""
_, ext = os.path.splitext(path)
if ext.strip(".") == "ttc": # pragma: no cover
fonts = ttCollection.TTCollection(path)
else:
fonts = [TTFont(path)]
font_faces = []
for font in fonts:
fonts = self._get_font_faces(font, path)
for f in fonts:
font_faces.append(f.FontName().ToCString())
f.SetSingleStrokeFont(single_stroke)
self.manager.RegisterFont(f, override)
return font_faces
def register_folder(
self, path: str, override: bool = False, single_stroke=False
) -> list[str]:
"""Register all fonts in a folder"""
exts = ["ttf", "otf", "ttc"]
font_faces = []
for ext in exts:
search = os.path.join(os.path.normpath(path), "*" + ext)
results = glob.glob(search)
for result in results:
font_faces += self.register_font(result, override, single_stroke)
return list(set(font_faces))
def register_system_fonts(self):
"""Runner to (re)inititalize the OCCT FontMgr font list since user folder is
missing on Windows and some fonts may not be imported correctly."""
if platform.system() == "Windows": # pragma: no cover
user = os.getlogin()
paths = [
"C:/Windows/Fonts",
f"C:/Users/{user}/AppData/Local/Microsoft/Windows/Fonts",
]
elif platform.system() == "Darwin": # pragma: no cover
# macOS
paths = ["/System/Library/Fonts", "/Library/Fonts"]
else:
paths = [
"/system/fonts", # Android
"/usr/share/fonts",
"/usr/local/share/fonts",
]
for path in paths:
self.register_folder(path)
def _get_font_faces(self, ft_font: TTFont, path: str) -> list[Font_SystemFont]: # pragma: no cover
"""Extract font info from font files and return list of font object."""
family, sub, preferred = "", "", ""
for record in ft_font["name"].names:
try:
value = record.toUnicode()
except:
continue
if record.nameID == 1 and family == "":
family = value
elif record.nameID == 2 and sub == "":
sub = value
elif record.nameID == 16 and preferred == "":
preferred = value
family = preferred if preferred != "" else family
if "fvar" in ft_font:
sub_ids = [i.subfamilyNameID for i in ft_font["fvar"].instances]
subfamilies = []
for record in ft_font["name"].names:
if record.nameID in sub_ids:
subfamilies.append(record.toUnicode())
else:
subfamilies = [sub]
# Replicate OCCT font aspect substitution rules, but make them correct
# - OCCT treats "Oblique" as "Italic", which seems fine
# - OCCT treats "Book" as "Regular", which is wrong
aspects = ["Regular", "Bold", "Italic", "Oblique"]
fonts: list[Font_SystemFont] = []
for i, subfamily in enumerate(subfamilies):
labels = subfamily.split()
matches = {aspect for aspect in aspects if aspect in labels}
if "Bold" in matches:
labels = [
label
for label in labels
if label not in ("Bold", "Italic", "Oblique")
]
if "Italic" in matches or "Oblique" in matches:
aspect = Font_FA_BoldItalic
else:
aspect = Font_FA_Bold
elif "Italic" in matches or "Oblique" in matches:
labels = [
label for label in labels if label not in ("Italic", "Oblique")
]
aspect = Font_FA_Italic
else:
labels = [] if "Regular" in matches else labels
aspect = Font_FA_Regular
subfamily = " ".join(labels)
font_name = " ".join([family, subfamily]) if subfamily != "" else family
font_name = font_name.strip()
ocp_font = Font_SystemFont(TCollection_AsciiString(font_name))
ocp_font.SetFontPath(aspect, TCollection_AsciiString(path), i << 16)
try:
# Some fonts have bad unicode characters in their name and I couldn't
# figure out how to fix them. Skipping these fonts for now
ocp_font.SetSingleStrokeFont(
ocp_font.FontKey().ToCString().startswith("olf ")
)
except UnicodeDecodeError:
return fonts
fonts.append(ocp_font)
return fonts
available_fonts = FontManager().available_fonts

View file

@ -55,8 +55,6 @@ license:
from __future__ import annotations
import copy
import os
import sys
import warnings
from collections.abc import Iterable, Iterator, Sequence
from itertools import combinations
@ -65,14 +63,6 @@ from typing_extensions import Self
import OCP.TopAbs as ta
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Fuse, BRepAlgoAPI_Section
from OCP.Font import (
Font_FA_Bold,
Font_FA_BoldItalic,
Font_FA_Italic,
Font_FA_Regular,
Font_FontMgr,
Font_SystemFont,
)
from OCP.gp import gp_Ax3
from OCP.Graphic3d import (
Graphic3d_HTA_LEFT,
@ -86,7 +76,6 @@ from OCP.Graphic3d import (
from OCP.GProp import GProp_GProps
from OCP.NCollection import NCollection_Utf8String
from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder, StdPrs_BRepFont
from OCP.TCollection import TCollection_AsciiString
from OCP.TopAbs import TopAbs_ShapeEnum
from OCP.TopoDS import (
TopoDS,
@ -107,6 +96,7 @@ from build123d.geometry import (
VectorLike,
logger,
)
from build123d.text import FONT_ASPECT, FontManager
from .one_d import Edge, Wire, Mixin1D
from .shape_core import (
@ -235,9 +225,7 @@ class Compound(Mixin3D[TopoDS_Compound]):
Returns:
Edge: extruded shape
"""
return Compound(
TopoDS.Compound(_extrude_topods_shape(obj.wrapped, direction))
)
return Compound(TopoDS.Compound(_extrude_topods_shape(obj.wrapped, direction)))
@classmethod
def make_text(
@ -251,31 +239,33 @@ class Compound(Mixin3D[TopoDS_Compound]):
align: Align | tuple[Align, Align] | None = None,
position_on_path: float = 0.0,
text_path: Edge | Wire | None = None,
single_line_width: float = 0.0,
) -> Compound:
"""2D Text that optionally follows a path.
"""Text that optionally follows a path.
The text that is created can be combined as with other sketch features by specifying
a mode or rotated by the given angle. In addition, edges have been previously created
a mode or rotated by the given angle. In addition, edges have been previously created
with arc or segment, the text will follow the path defined by these edges. The start
parameter can be used to shift the text along the path to achieve precise positioning.
Args:
txt: text to be rendered
font_size: size of the font in model units
font: font name
font_path: path to font file
font_style: text style. Defaults to FontStyle.REGULAR
txt (str): text to render
font_size (float): size of the font in model units
font (str, optional): font name. Defaults to "Arial"
font_path (str, optional): system path to font file. Defaults to None
font_style (Font_Style, optional): font style, REGULAR, BOLD, BOLDITALIC, or
ITALIC. Defaults to Font_Style.REGULAR
text_align (tuple[TextAlign, TextAlign], optional): horizontal text align
LEFT, CENTER, or RIGHT. Vertical text align BOTTOM, CENTER, TOP, or
TOPFIRSTLINE. Defaults to (TextAlign.CENTER, TextAlign.CENTER)
align (Union[Align, tuple[Align, Align]], optional): align min, center, or max
of object. Defaults to None
position_on_path: the relative location on path to position the text,
between 0.0 and 1.0. Defaults to 0.0
text_path: a path for the text to follows. Defaults to None (linear text)
Returns:
a Compound object containing multiple Faces representing the text
align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of
object. Defaults to None
position_on_path (float, optional): the relative location on path to position
the text, values must be between 0.0 and 1.0. Defaults to 0.0
text_path: (Edge | Wire, optional): path for text to follow. Defaults to None
Compound object containing multiple Shapes representing the text
single_line_width (float): width of outlined single line font.
Defaults to 0.0
Examples::
@ -289,39 +279,35 @@ class Compound(Mixin3D[TopoDS_Compound]):
"""
# pylint: disable=too-many-locals
def position_face(orig_face: Face) -> Face:
"""
Reposition a face to the provided path
def position_glyph(glyph: Shape, path: Edge | Wire, position: float) -> Shape:
"""Reposition a glyph shape on provided path
Local coordinates are used to calculate the position of the face
relative to the path. Global coordinates to position the face.
Local coordinates are used to calculate the position of the shape
relative to the path. Global coordinates to position the shape.
"""
assert text_path is not None
bbox = orig_face.bounding_box()
bbox = glyph.bounding_box()
face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0)
relative_position_on_wire = (
position_on_path + face_bottom_center.X / path_length
)
wire_tangent = text_path.tangent_at(relative_position_on_wire)
relative_position_on_wire = position + face_bottom_center.X / path.length
wire_tangent = path.tangent_at(relative_position_on_wire)
wire_angle = Vector(1, 0, 0).get_signed_angle(wire_tangent)
wire_position = text_path.position_at(relative_position_on_wire)
wire_position = path.position_at(relative_position_on_wire)
return orig_face.translate(wire_position - face_bottom_center).rotate(
return glyph.translate(wire_position - face_bottom_center).rotate(
Axis(wire_position, (0, 0, 1)),
-wire_angle,
)
if sys.platform.startswith("linux"):
os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf"
os.environ["FONTCONFIG_PATH"] = "/etc/fonts/"
font_kind = {
FontStyle.REGULAR: Font_FA_Regular,
FontStyle.BOLD: Font_FA_Bold,
FontStyle.ITALIC: Font_FA_Italic,
FontStyle.BOLDITALIC: Font_FA_BoldItalic,
}[font_style]
manager = FontManager()
if font_path and manager.check_font(font_path): # pragma: no cover
face_names = manager.register_font(font_path, True, False)
# Check if font (name) is in face names and not bad or default (Arial)
font_name = font if font in face_names else face_names[0]
system_font = manager.find_font(font_name, font_style)
else:
system_font = manager.find_font(font, font_style)
# Validate TextAlign parameters
if text_align[0] not in [TextAlign.LEFT, TextAlign.CENTER, TextAlign.RIGHT]:
raise ValueError(
"Horizontal TextAlign must be LEFT, CENTER, or RIGHT. "
@ -352,33 +338,27 @@ class Compound(Mixin3D[TopoDS_Compound]):
TextAlign.TOPFIRSTLINE: Graphic3d_VTA_TOPFIRSTLINE,
}[text_align[1]]
mgr = Font_FontMgr.GetInstance_s()
if font_path and mgr.CheckFont(TCollection_AsciiString(font_path).ToCString()):
font_t = Font_SystemFont(TCollection_AsciiString(font_path))
font_t.SetFontPath(font_kind, TCollection_AsciiString(font_path))
mgr.RegisterFont(font_t, True)
else:
font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind)
logger.info(
"Creating text with font %s located at %s",
font_t.FontName().ToCString(),
font_t.FontPath(font_kind).ToCString(),
system_font.FontName().ToCString(),
system_font.FontPath(FONT_ASPECT[font_style]).ToCString(),
)
# Write text to shape
builder = Font_BRepTextBuilder()
font_i = StdPrs_BRepFont(
NCollection_Utf8String(font_t.FontName().ToCString()),
font_kind,
brep_font = StdPrs_BRepFont(
NCollection_Utf8String(system_font.FontName().ToCString()),
FONT_ASPECT[font_style],
float(font_size),
)
if system_font.IsSingleStrokeFont():
brep_font.SetCompositeCurveMode(False)
text_flat = Compound(
TopoDS.Compound(
builder.Perform(
font_i,
brep_font,
NCollection_Utf8String(txt),
gp_Ax3(),
horiz_align,
@ -393,9 +373,29 @@ class Compound(Mixin3D[TopoDS_Compound]):
Vector(*text_flat.bounding_box().to_align_offset(align_text))
)
if text_path is not None:
path_length = text_path.length
text_flat = Compound([position_face(f) for f in text_flat.faces()])
# Place text on path
if text_path:
glyphs = text_flat.get_top_level_shapes()
text_flat = Compound(
[position_glyph(g, text_path, position_on_path) for g in glyphs]
)
def _make_face(edges: Iterable[Edge]) -> Face:
face = Face(Wire.combine(edges)[0])
if face.normal_at().Z < 0: # flip up-side-down faces
face = -face # pylint: disable=E1130
return face
# Outline single line text
# offset_2d distance is radius, treat single_line_width as diameter/overall height
if system_font.IsSingleStrokeFont() and single_line_width > 0:
outline = [e.offset_2d(single_line_width / 2) for e in text_flat.edges()]
outline = [_make_face(o.edges()) for o in outline]
text_flat = Compound([]) + outline
if any([not f.is_valid for f in text_flat.get_top_level_shapes()]):
raise ValueError(
f"single_line_width ({single_line_width}) is too large for the text and produces invalid faces. Try a smaller width"
)
return text_flat
@ -412,14 +412,14 @@ class Compound(Mixin3D[TopoDS_Compound]):
arrow = Wire([arrow_arc, copy.copy(arrow_arc).mirror(Plane.XZ)])
x_label = (
Compound.make_text(
"X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
"X", axes_scale / 4, "singleline", align=(Align.MIN, Align.CENTER)
)
.move(Location(x_axis @ 1))
.edges()
)
y_label = (
Compound.make_text(
"Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
"Y", axes_scale / 4, "singleline", align=(Align.MIN, Align.CENTER)
)
.rotate(Axis.Z, 90)
.move(Location(y_axis @ 1))
@ -427,7 +427,7 @@ class Compound(Mixin3D[TopoDS_Compound]):
)
z_label = (
Compound.make_text(
"Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN)
"Z", axes_scale / 4, "singleline", align=(Align.CENTER, Align.MIN)
)
.rotate(Axis.Y, 90)
.rotate(Axis.X, 90)

View file

@ -1,57 +0,0 @@
"""
Helper Utilities
name: utils.py
by: jwagenet
date: July 28th 2025
desc:
This python module contains helper utilities not related to object creation.
"""
from dataclasses import dataclass
from build123d.build_enums import FontStyle
from OCP.Font import (
Font_FA_Bold,
Font_FA_BoldItalic,
Font_FA_Italic,
Font_FA_Regular,
Font_FontMgr,
)
@dataclass(frozen=True)
class FontInfo:
name: str
styles: tuple[FontStyle, ...]
def __repr__(self) -> str:
style_names = tuple(s.name for s in self.styles)
return f"Font(name={self.name!r}, styles={style_names})"
def available_fonts() -> list[FontInfo]:
"""Get list of available fonts by name and available styles (also called aspects).
Note: on Windows, fonts must be installed with "Install for all users" to be found.
"""
font_aspects = {
"REGULAR": Font_FA_Regular,
"BOLD": Font_FA_Bold,
"BOLDITALIC": Font_FA_BoldItalic,
"ITALIC": Font_FA_Italic,
}
manager = Font_FontMgr.GetInstance_s()
font_list = []
for f in manager.GetAvailableFonts():
avail_aspects = tuple(
FontStyle[n] for n, a in font_aspects.items() if f.HasFontAspect(a)
)
font_list.append(FontInfo(f.FontName().ToCString(), avail_aspects))
font_list.sort(key=lambda x: x.name)
return font_list

View file

@ -383,6 +383,22 @@ class TestBuildSketchObjects(unittest.TestCase):
self.assertEqual(len(test.sketch.faces()), 4)
self.assertEqual(t.faces()[0].normal_at(), Vector(0, 0, 1))
def test_text_singleline(self):
font_size = 10
singleline = Text("test", font_size, "singleline")
self.assertTrue(all([isinstance(s, Face) for s in singleline.get_top_level_shapes()]))
self.assertEqual(singleline.single_line_width, font_size * .04)
singlelinewidth = Text("test", font_size, "singleline", single_line_width=1)
self.assertEqual(singlelinewidth.single_line_width, 1)
with self.assertRaises(ValueError):
Text("test", font_size, "singleline", single_line_width=0)
with self.assertRaises(ValueError):
Text("the quick brown fox", font_size, "singleline", single_line_width=6)
def test_text_exceptions(self):
with self.assertRaises(ValueError):
Text("test", 2, text_align=(TextAlign.BOTTOM, TextAlign.BOTTOM))

View file

@ -28,12 +28,14 @@ license:
import itertools
import unittest
from pathlib import Path
from build123d.build_common import GridLocations, PolarLocations
from build123d.build_enums import Align, CenterOf
from build123d.geometry import Location, Plane
from build123d.objects_part import Box
from build123d.objects_sketch import Circle
from build123d.text import FontManager
from build123d.topology import Compound, Edge, Face, ShapeList, Solid, Sketch
@ -47,6 +49,14 @@ class TestCompound(unittest.TestCase):
)
self.assertEqual(len(text.faces()), 4)
singleline = Compound.make_text("test", 10, "singleline", text_path=arc)
outline = Compound.make_text(
"test", 10, "singleline", text_path=arc, single_line_width=0.2
)
self.assertEqual(len(singleline.faces()), 0)
self.assertGreaterEqual(len(singleline.wires()), 4)
self.assertEqual(len(outline.faces()), 4)
def test_fuse(self):
box1 = Solid.make_box(1, 1, 1)
box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))

155
tests/test_text.py Normal file
View file

@ -0,0 +1,155 @@
"""
build123d Font and Text Utilities tests
name: test_text.py
by: jwagenet
date: July 28th 2025
desc: Unit tests for the build123d font and text module
"""
import unittest
from pathlib import Path
from OCP.TCollection import TCollection_AsciiString
from build123d import available_fonts, FontStyle
from build123d.text import FONT_ASPECT, FontInfo, FontManager
class TestFontManager(unittest.TestCase):
"""Tests for FontManager."""
def test_persistence(self):
"""OCP FontMgr expected to persist db over multiple instances"""
instance1 = FontManager()
instance1.manager.ClearFontDataBase()
working_path = Path(__file__).resolve().parent
src_path = Path("src/build123d")
font_name = instance1.bundled_fonts[0][1]
font_path = (working_path.parent / src_path / instance1.bundled_path / font_name)
instance1.register_font(str(font_path))
instance2 = FontManager()
self.assertEqual(instance1.available_fonts(), instance2.available_fonts())
def test_register_font(self):
"""Expected to return system font with matching name if it exists"""
manager = FontManager()
manager.manager.ClearFontDataBase()
working_path = Path(__file__).resolve().parent
src_path = Path("src/build123d")
font_name = manager.bundled_fonts[0][1]
font_path = (working_path.parent / src_path / manager.bundled_path / font_name).resolve()
font_names = manager.register_font(str(font_path))
result = manager.find_font(font_names[0], FontStyle.REGULAR)
self.assertEqual(font_names[0], result.FontName().ToCString())
def test_register_folder(self):
"""Expected to register fonts in folder"""
manager = FontManager()
manager.manager.ClearFontDataBase()
working_path = Path(__file__).resolve().parent
src_path = Path("src/build123d")
font_name = manager.bundled_fonts[0][0]
font_file = Path(manager.bundled_fonts[0][1])
font_folder = font_file.parent
folder_path = (working_path.parent / src_path / manager.bundled_path / font_folder).resolve()
font_names = manager.register_folder(str(folder_path))
result = manager.find_font(font_names[0], FontStyle.REGULAR)
self.assertEqual(font_name, result.FontName().ToCString())
def test_register_system_fonts(self):
"""Expected to register at least as many fonts from before.
May find more on Windows
"""
manager = FontManager()
available_before = manager.available_fonts()
manager.manager.RemoveFontAlias(
TCollection_AsciiString("singleline"),
TCollection_AsciiString("Relief SingleLine CAD"),
)
manager.manager.ClearFontDataBase()
manager.register_system_fonts()
# add bundled fonts back in
manager.__init__()
available_after = manager.available_fonts()
self.assertGreaterEqual(len(available_after), len(available_before))
def test_check_font(self):
"""Expected to return system font with matching path if it exists or None"""
manager = FontManager()
working_path = Path(__file__).resolve().parent
src_path = Path("src/build123d")
font_name = manager.bundled_fonts[0][1]
good_path = (working_path.parent / src_path / manager.bundled_path / font_name).resolve()
good_font = manager.check_font(str(good_path))
bad_font = manager.check_font(font_name)
aspect = FONT_ASPECT[FontStyle.REGULAR]
self.assertEqual(str(good_path), good_font.FontPath(aspect).ToCString())
self.assertIsNone(bad_font)
def test_find_font(self):
"""Expected to return font with matching name if it exists"""
manager = FontManager()
good_name = manager.bundled_fonts[0][0]
good_font = manager.find_font(good_name, FontStyle.REGULAR)
bad_font = manager.find_font("build123d", FontStyle.REGULAR)
self.assertEqual(good_name, good_font.FontName().ToCString())
self.assertNotEqual("build123d", bad_font.FontName().ToCString())
class TestFontHelpers(unittest.TestCase):
"""Tests for font helpers."""
def test_font_info(self):
"""Test expected FontInfo repr."""
name = "Arial"
styles = tuple(member for member in FontStyle)
font = FontInfo(name, styles)
self.assertEqual(
repr(font),
f"Font(name={name!r}, styles={tuple(s.name for s in styles)})",
)
def test_available_fonts(self):
"""Test expected output for available fonts."""
fonts = available_fonts()
self.assertIsInstance(fonts, list)
for font in fonts:
self.assertIsInstance(font, FontInfo)
self.assertIsInstance(font.name, str)
self.assertIsInstance(font.styles, tuple)
for style in font.styles:
self.assertIsInstance(style, FontStyle)
names = [font.name for font in fonts]
self.assertEqual(names, sorted(names))
if __name__ == "__main__":
unittest.main()

View file

@ -1,46 +0,0 @@
"""
build123d Helper Utilities tests
name: test_utils.py
by: jwagenet
date: July 28th 2025
desc: Unit tests for the build123d helper utilities module
"""
import unittest
from build123d import *
from build123d.utils import FontInfo
class TestFontHelpers(unittest.TestCase):
"""Tests for font helpers."""
def test_font_info(self):
"""Test expected FontInfo repr."""
name = "Arial"
styles = tuple(member for member in FontStyle)
font = FontInfo(name, styles)
self.assertEqual(
repr(font), f"Font(name={name!r}, styles={tuple(s.name for s in styles)})"
)
def test_available_fonts(self):
"""Test expected output for available fonts."""
fonts = available_fonts()
self.assertIsInstance(fonts, list)
for font in fonts:
self.assertIsInstance(font, FontInfo)
self.assertIsInstance(font.name, str)
self.assertIsInstance(font.styles, tuple)
for style in font.styles:
self.assertIsInstance(style, FontStyle)
names = [font.name for font in fonts]
self.assertEqual(names, sorted(names))
if __name__ == "__main__":
unittest.main()