"""Multi-line bitmap text rendering with antialiasing and optional sharpening."""
from goojprt.constants import PAPER_WIDTH_PX
from goojprt.encoding import find_system_font
from goojprt.enums import Align
[docs]
def render_text_image(
text: str,
font_size: int = 24,
font_path: str | None = None,
line_spacing: int = 6,
padding: int = 8,
align: "Align | None" = None,
supersample: int = 3,
dither: bool = True,
threshold: int = 140,
sharpen: bool = False,
sharpen_radius: float = 1.5,
sharpen_percent: int = 180,
sharpen_threshold: int = 2,
):
"""Render a multi-line string as a 1-bit image ready for the print head.
The pipeline has four stages:
1. **Supersampled rendering.** The text is drawn into a grayscale
canvas ``supersample`` times larger than the final output. PIL's
builtin antialiasing produces fully smooth diagonals with no
staircasing.
2. **Downscale to paper width** using ``LANCZOS`` resampling. The
grayscale transitions carry the antialiasing information into the
target resolution.
3. **Optional unsharp-mask sharpening.** Applied before 1-bit
conversion to boost perceived sharpness on thermal paper.
4. **1-bit conversion** suitable for the print head:
* ``dither=True`` — Floyd–Steinberg error diffusion. Best choice
for small / body text: the grayscale gradient is converted into
a dot pattern that preserves the antialiased look.
* ``dither=False`` — pure threshold. Perfectly crisp edges, ideal
for large headlines and barcodes.
:param text: Multi-line input text (``\\n`` splits lines).
:param font_size: Font size in points (rendered at
``font_size × supersample``).
:param font_path: Optional path to a ``.ttf`` / ``.ttc`` file. Falls
back to :func:`find_system_font` when ``None``.
:param line_spacing: Extra pixels between lines (pre-supersample).
:param padding: Outer canvas padding in pixels (pre-supersample).
:param align: Horizontal alignment (:class:`Align`), default
``Align.LEFT``.
:param supersample: Oversampling factor 1–4 (default ``3``, a good
quality/speed trade-off).
:param dither: ``True`` for Floyd–Steinberg, ``False`` for threshold.
:param threshold: Threshold 0–255 for the ``dither=False`` path
(lower = darker).
:param sharpen: Apply :class:`PIL.ImageFilter.UnsharpMask` before
1-bit conversion.
:param sharpen_radius: Blur radius of the unsharp mask.
:param sharpen_percent: Unsharp-mask intensity in percent.
:param sharpen_threshold: Minimum brightness delta that triggers
sharpening.
:returns: PIL image in mode ``"1"`` with width
:data:`~goojprt.constants.PAPER_WIDTH_PX`.
"""
from PIL import Image as PILImage, ImageDraw, ImageFont, ImageFilter
ss = max(1, supersample)
render_width = PAPER_WIDTH_PX * ss
resolved_font = font_path or find_system_font()
try:
# Font is rendered at the super-sampled size.
font = ImageFont.truetype(resolved_font, font_size * ss) if resolved_font \
else ImageFont.load_default()
except (OSError, IOError):
font = ImageFont.load_default()
lines = text.splitlines() or [""]
probe_draw = ImageDraw.Draw(PILImage.new("L", (1, 1)))
line_dims: list[tuple[int, int]] = []
for line in lines:
bb = probe_draw.textbbox((0, 0), line or " ", font=font)
line_dims.append((int(bb[2] - bb[0]), int(bb[3] - bb[1])))
pad = padding * ss
sp = line_spacing * ss
render_height = sum(h for _, h in line_dims) + sp * (len(lines) - 1) + 2 * pad
# Draw on a large grayscale canvas so PIL performs full antialiasing.
canvas = PILImage.new("L", (render_width, render_height), 255)
draw = ImageDraw.Draw(canvas)
y = pad
for i, line in enumerate(lines):
lw, lh = line_dims[i]
align_val = int(align) if align is not None else 0
if align_val == 0: # LEFT
x = pad
elif align_val == 1: # CENTER
x = (render_width - lw) // 2
else: # RIGHT
x = render_width - lw - pad
draw.text((int(x), int(y)), line, font=font, fill=0)
y += lh + sp
# Downsample to the print head width — LANCZOS is the best choice here.
target_height = max(1, round(render_height / ss))
img = canvas.resize(
(PAPER_WIDTH_PX, target_height),
resample=PILImage.Resampling.LANCZOS,
)
# Optional sharpening before 1-bit conversion.
if sharpen:
img = img.filter(ImageFilter.UnsharpMask(
radius=sharpen_radius,
percent=sharpen_percent,
threshold=sharpen_threshold,
))
# 1-bit conversion for the print head.
if dither:
# Floyd–Steinberg: per-pixel error is propagated to neighbours,
# producing a dot pattern that preserves the antialiased look.
return img.convert("1")
# Pure threshold: razor-sharp edges with zero noise around the glyphs.
return img.point(lambda p: 0 if p < threshold else 255).convert("1") # type: ignore[arg-type]