From 964be957be4a9511cf32e2ac5e1ace7debd8aaa2 Mon Sep 17 00:00:00 2001 From: Joshua Wise Date: Sun, 6 Jul 2025 16:37:56 -0400 Subject: [PATCH 1/5] add single-line font rendering, based off of some heavily hacked harfbuzz/freetype samples I found --- brother_pt/__main__.py | 45 +++++- brother_pt/cmd.py | 3 +- brother_pt/font.py | 328 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 +- setup.py | 3 + 5 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 brother_pt/font.py diff --git a/brother_pt/__main__.py b/brother_pt/__main__.py index 42b4cb6..fccfa8b 100644 --- a/brother_pt/__main__.py +++ b/brother_pt/__main__.py @@ -17,7 +17,8 @@ from brother_pt import VERSION from .printer import * - +from .cmd import * +from .font import * def show_status(serial): printers = find_printers(serial) @@ -27,6 +28,7 @@ def show_status(serial): found_printer = BrotherPt(printers[0].serial_number) print("%s %s (%s):" % (printers[0].manufacturer, printers[0].product, printers[0].serial_number)) print(" + Media width: %dmm" % found_printer.media_width) + print(" (%dpx)" % MediaWidthToTapeMargin.to_print_width(found_printer.media_width)) print(" + Media type : %s" % found_printer.media_type.name) print(" + Tape color : %s" % found_printer.tape_color.name) print(" + Text color : %s" % found_printer.text_color.name) @@ -85,6 +87,35 @@ def do_print(args): return 0 +def do_text(args): + printers = find_printers(args.printer) + if len(printers) == 0: + print("No supported printers found, make sure the device is switched on", file=sys.stderr) + return 1 + + found_printer = BrotherPt(printers[0].serial_number) + required_height = MediaWidthToTapeMargin.to_print_width(found_printer.media_width) + + font = Font(pattern = args.font, size = args.size) + image = font.render_text(args.text, height = required_height, center = True).pixels + + # Margin check + margin = args.margin + if (image.width + margin) < MINIMUM_TAPE_POINTS: + print("Image (%i) + cut margin (%i) is smaller than minimum tape width (%i) ...\n" + "cutting length will be extended" % (image.width, margin, MINIMUM_TAPE_POINTS)) + margin = MINIMUM_TAPE_POINTS - image.width + + # Raster image + data = raster_image(image, found_printer.media_width) + image.show() + return + + found_printer.print_data(data, margin) + + return 0 + + def list_printers(serial): printers = find_printers(serial) if len(printers) == 0: @@ -120,7 +151,7 @@ def cli(): discover.set_defaults(cmd='info') # Complex subparsers - print_menu = subparsers.add_parser('print') + print_menu = subparsers.add_parser('print', help="Print an image") print_menu.add_argument("-r", "--rotate", default='auto', choices=['auto', '0', '90', '180', '270'], help='Rotate the image (counter clock-wise) by this amount of degrees. ' @@ -133,6 +164,14 @@ def cli(): print_menu.add_argument("-f", "--file", type=str, required=True, help="Image file to print") print_menu.set_defaults(cmd='print') + text_menu = subparsers.add_parser('text', help="Print some text") + text_menu.add_argument("-m", "--margin", type=int, default=30, + help="Print margin in dots.") + text_menu.add_argument("-f", "--font", type=str, default="Arial", help="Font face (ok, actually, fontconfig pattern)") + text_menu.add_argument("-s", "--size", type=int, default=24, help="Font size") + text_menu.add_argument("text", type=str, help="Text to print") + text_menu.set_defaults(cmd='text') + args = parser.parse_args() if args.version: @@ -155,6 +194,8 @@ def cli(): return 0 elif args.cmd == 'print': return do_print(args) + elif args.cmd == 'text': + return do_text(args) return 0 diff --git a/brother_pt/cmd.py b/brother_pt/cmd.py index e1b33a7..593217f 100644 --- a/brother_pt/cmd.py +++ b/brother_pt/cmd.py @@ -19,7 +19,8 @@ PRINT_HEAD_PINS = 128 USBID_BROTHER = 0x04f9 LINE_LENGTH_BYTES = 0x10 -MINIMUM_TAPE_POINTS = 174 # 25.4 mm @ 180dpi +#MINIMUM_TAPE_POINTS = 174 # 25.4 mm @ 180dpi +MINIMUM_TAPE_POINTS = 50 USB_OUT_EP_ID = 0x2 USB_IN_EP_ID = 0x81 USB_TRX_TIMEOUT_MS = 15000 diff --git a/brother_pt/font.py b/brother_pt/font.py new file mode 100644 index 0000000..f531302 --- /dev/null +++ b/brother_pt/font.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Needs freetype-py>=1.0 + +# For more info see: +# http://dbader.org/blog/monochrome-font-rendering-with-freetype-and-python + +# Updated by Joseph Solomon 2016 + +# The MIT License (MIT) +# +# Copyright (c) 2013 Daniel Bader (http://dbader.org) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from math import ceil +import functools + +import fontconfig +import freetype +from vharfbuzz import Vharfbuzz +from PIL import Image + +class Bitmap(object): + """ + A 2D bitmap image represented as a list of byte values. Each byte indicates the state + of a single pixel in the bitmap. A value of 0 indicates that the pixel is `off` + and any other value indicates that it is `on`. + """ + + def __init__(self, width, height, pixels=None): + self.width = width + self.height = height + self.pixels = Image.frombytes("L", (width, height), bytes(pixels or [0 for i in range(width * height)])).point(lambda x: 1 if x != 0 else 0, "1") + + def __repr__(self): + """Return a string representation of the bitmap's pixels.""" + rows = '' + for y in range(self.height): + for x in range(self.width): + rows += '#' if self.pixels.getpixel((x, y)) else '.' + rows += '\n' + return rows + + def bitblt(self, src, x, y): + """Copy all pixels from `src` into this bitmap""" + self.pixels.paste(src.pixels, (x, y), src.pixels) + +class Glyph(object): + def __init__(self, pixels, width, height, top, advance_width): + self.bitmap = Bitmap(width, height, pixels) + + # The glyph bitmap's top-side bearing, i.e. the vertical distance from the + # baseline to the bitmap's top-most scanline. + self.top = top + + # Ascent and descent determine how many pixels the glyph extends + # above or below the baseline. + self.descent = max(0, self.height - self.top) + self.ascent = max(0, max(self.top, self.height) - self.descent) + + # The advance width determines where to place the next character horizontally, + # that is, how many pixels we move to the right to draw the next glyph. + self.advance_width = advance_width + + @property + def width(self): + return self.bitmap.width + + @property + def height(self): + return self.bitmap.height + + @staticmethod + def from_glyphslot(slot): + """Construct and return a Glyph object from a FreeType GlyphSlot.""" + pixels = Glyph.unpack_mono_bitmap(slot.bitmap) + width, height = slot.bitmap.width, slot.bitmap.rows + top = slot.bitmap_top + + # The advance width is given in FreeType's 26.6 fixed point format, + # which means that the pixel values are multiples of 64. + advance_width = slot.advance.x // 64 + + return Glyph(pixels, width, height, top, advance_width) + + @staticmethod + def unpack_mono_bitmap(bitmap): + """ + Unpack a freetype FT_LOAD_TARGET_MONO glyph bitmap into a bytearray where each + pixel is represented by a single byte. + """ + # Allocate a bytearray of sufficient size to hold the glyph bitmap. + data = [0 for i in range(bitmap.rows * bitmap.width)] + + # Iterate over every byte in the glyph bitmap. Note that we're not + # iterating over every pixel in the resulting unpacked bitmap -- + # we're iterating over the packed bytes in the input bitmap. + for y in range(bitmap.rows): + for byte_index in range(bitmap.pitch): + + # Read the byte that contains the packed pixel data. + byte_value = bitmap.buffer[y * bitmap.pitch + byte_index] + + # We've processed this many bits (=pixels) so far. This determines + # where we'll read the next batch of pixels from. + num_bits_done = byte_index * 8 + + # Pre-compute where to write the pixels that we're going + # to unpack from the current byte in the glyph bitmap. + rowstart = y * bitmap.width + byte_index * 8 + + # Iterate over every bit (=pixel) that's still a part of the + # output bitmap. Sometimes we're only unpacking a fraction of a byte + # because glyphs may not always fit on a byte boundary. So we make sure + # to stop if we unpack past the current row of pixels. + for bit_index in range(min(8, bitmap.width - num_bits_done)): + + # Unpack the next pixel from the current glyph byte. + bit = byte_value & (1 << (7 - bit_index)) + + # Write the pixel to the output bytearray. We ensure that `off` + # pixels have a value of 0 and `on` pixels have a value of 1. + data[rowstart + bit_index] = 1 if bit else 0 + + return data + + +class Font(object): + def __init__(self, filename = None, pattern = None, size = 24): + if pattern: + pat = fontconfig.Pattern.parse(pattern) + pat.default_substitute() + fontmatch = fontconfig.Config.get_current().font_match(pat) + print(f"want font {pattern}") + print(f"got pattern {fontmatch}") + filename = fontmatch.get('file') + #assert(False) + + assert(filename) + + self.face = freetype.Face(filename) + self.face.set_pixel_sizes(0, size) + self.size = size + self.harfbuzz = Vharfbuzz(filename) + self.harfbuzz.hbfont.scale = (size * 64, size * 64) + + def __del__(self): + del(self.face) + + @functools.cache + def glyph_for_character(self, char): + # Let FreeType load the glyph for the given character and tell it to render + # a monochromatic bitmap representation. + self.face.load_char(char, freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO) + return Glyph.from_glyphslot(self.face.glyph) + + @functools.cache + def glyph_for_glyphid(self, glyph): + # Let FreeType load the glyph for the given character and tell it to render + # a monochromatic bitmap representation. + self.face.load_glyph(glyph, freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO) + return Glyph.from_glyphslot(self.face.glyph) + + def render_character(self, char): + glyph = self.glyph_for_character(char) + return glyph.bitmap + + def kerning_offset(self, previous_char, char): + """ + Return the horizontal kerning offset in pixels when rendering `char` + after `previous_char`. + Use the resulting offset to adjust the glyph's drawing position to + reduces extra diagonal whitespace, for example in the string "AV" the + bitmaps for "A" and "V" may overlap slightly with some fonts. In this + case the glyph for "V" has a negative horizontal kerning offset as it is + moved slightly towards the "A". + """ + kerning = self.face.get_kerning(previous_char, char) + + # The kerning offset is given in FreeType's 26.6 fixed point format, + # which means that the pixel values are multiples of 64. + return -kerning.x // 64 + + def text_dimensions(self, text): + """Return (width, height, baseline) of `text` rendered in the current font.""" + width = 0 + max_ascent = 0 + max_descent = 0 + advance = 0 + lastwidth = 0 + lastadvance = 0 + + hb_buf = self.harfbuzz.shape(text, {"features": {"kern": True, "liga": True}}) + + # For each character in the text string we get the glyph + # and update the overall dimensions of the resulting bitmap. + for info, pos in zip(hb_buf.glyph_infos, hb_buf.glyph_positions): + print(self.harfbuzz.hbfont.glyph_to_string(info.codepoint), info.cluster, pos.x_advance, pos.x_offset, pos.y_offset) + glyph = self.glyph_for_glyphid(info.codepoint) + max_ascent = max(max_ascent, glyph.ascent) + max_descent = max(max_descent, glyph.descent) + width += ceil(pos.x_advance / 64) + + height = max_ascent + max_descent + return (width, height, max_descent) + + def render_text(self, text, width=None, height=None, baseline=None, center=False): + """ + Render the given `text` into a Bitmap and return it. + If `width`, `height`, and `baseline` are not specified they are computed using + the `text_dimensions' method. + """ + # Do centering here + new_width, new_height, new_baseline = self.text_dimensions(text) + width = width or new_width + if baseline: + if not height: + height = new_height - new_baseline + baseline + baseline = baseline + else: + baseline = new_baseline + height = height or new_height + + x_offset = 0 + y_offset = 0 + if center: + if width > new_width: + x_offset = (width - new_width) // 2 + if height > new_height: + y_offset = (height - new_height) // 2 + + x = 0 + previous_char = None + outbuffer = Bitmap(width, height) + + hb_buf = self.harfbuzz.shape(text) + + for info, pos in zip(hb_buf.glyph_infos, hb_buf.glyph_positions): + glyph = self.glyph_for_glyphid(info.codepoint) + + # The vertical drawing position should place the glyph + # on the baseline as intended. + y = height - glyph.ascent - baseline + pos.y_offset // 64 + + outbuffer.bitblt(glyph.bitmap, x + x_offset + pos.x_offset // 64, y - y_offset) + + x += pos.x_advance // 64 + + return outbuffer + + def render_texts(self, texts, width=None, height=None, baseline=None, center=True, spacing=2): + + outbuffer = None + + for text in texts: + text_buffer = self.render_text(text, baseline=baseline) + if outbuffer: + cur_height = outbuffer.height + new_buffer = Bitmap(max(text_buffer.width, outbuffer.width), + text_buffer.height + cur_height + spacing) + if center: + x_offset = (new_buffer.width - outbuffer.width) // 2 + else: + x_offset = 0 + new_buffer.bitblt(outbuffer, x_offset, 0) + outbuffer = new_buffer + + if center: + x_offset = (outbuffer.width - text_buffer.width) // 2 + else: + x_offset = 0 + outbuffer.bitblt(text_buffer, x_offset, cur_height + spacing) + else: + outbuffer = text_buffer + + width = width or outbuffer.width + height = height or outbuffer.height + if center: + x_offset = (width - outbuffer.width) // 2 + y_offset = (height - outbuffer.height) // 2 + else: + x_offset = 0 + y_offset = 0 + final_buffer = Bitmap(width, height) + final_buffer.bitblt(outbuffer, x_offset, y_offset) + + return final_buffer + + +if __name__ == '__main__': + # Be sure to place 'helvetica.ttf' (or any other ttf / otf font file) in the working directory. + fnt = Font(pattern="Noto Sans", size=14) + + # Single characters + ch = fnt.render_character('e') + print(repr(ch)) + + # Multiple characters + txt = fnt.render_text('hello') + print(repr(txt)) + + # Kerning + print(repr(fnt.render_text('AV Wa'))) + print(repr(fnt.render_text('hello,', 64, 32, 2, center=True))) + + # Choosing the baseline correctly + print(repr(fnt.render_text('hello, .gjp', 64, 32, 2, center=True))) + print(repr(fnt.render_texts(['hello, world.', 'gjp'], 64, 48, 2, center=False, spacing=4))) + + print(fnt.render_text('e')) + print(fnt.render_text('e').pixels) diff --git a/requirements.txt b/requirements.txt index 07f4ecd..445d892 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ pyusb==1.2.1 Pillow==8.4.0 -packbits==0.6 \ No newline at end of file +packbits==0.6 +freetype-py==2.5.1 +fontconfig-py==0.1.2 +vharfbuzz==0.3.1 \ No newline at end of file diff --git a/setup.py b/setup.py index c606fa7..c9424dc 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,9 @@ 'pyusb>=1.2.1', 'Pillow==8.4.0', 'packbits==0.6', + 'freetype-py==2.5.1', + 'fontconfig-py==0.1.2', + 'vharfbuzz==0.3.1', ], ) From c8619b05649ea24d28640f5dd55a1e9b77b57db4 Mon Sep 17 00:00:00 2001 From: Joshua Wise Date: Sun, 6 Jul 2025 17:36:11 -0400 Subject: [PATCH 2/5] add a preview option, center the text body but not the descenders, fix horizontal centering, choose better margin defaults --- brother_pt/__main__.py | 8 +++++--- brother_pt/font.py | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/brother_pt/__main__.py b/brother_pt/__main__.py index fccfa8b..00c9493 100644 --- a/brother_pt/__main__.py +++ b/brother_pt/__main__.py @@ -108,8 +108,9 @@ def do_text(args): # Raster image data = raster_image(image, found_printer.media_width) - image.show() - return + if args.preview: + image.show() + return found_printer.print_data(data, margin) @@ -165,10 +166,11 @@ def cli(): print_menu.set_defaults(cmd='print') text_menu = subparsers.add_parser('text', help="Print some text") - text_menu.add_argument("-m", "--margin", type=int, default=30, + text_menu.add_argument("-m", "--margin", type=int, default=50, help="Print margin in dots.") text_menu.add_argument("-f", "--font", type=str, default="Arial", help="Font face (ok, actually, fontconfig pattern)") text_menu.add_argument("-s", "--size", type=int, default=24, help="Font size") + text_menu.add_argument("--preview", action='store_true', help="just show a preview, don't print a label") text_menu.add_argument("text", type=str, help="Text to print") text_menu.set_defaults(cmd='text') diff --git a/brother_pt/font.py b/brother_pt/font.py index f531302..350d4cf 100644 --- a/brother_pt/font.py +++ b/brother_pt/font.py @@ -216,7 +216,9 @@ def text_dimensions(self, text): glyph = self.glyph_for_glyphid(info.codepoint) max_ascent = max(max_ascent, glyph.ascent) max_descent = max(max_descent, glyph.descent) - width += ceil(pos.x_advance / 64) + width += pos.x_advance // 64 + width -= pos.x_advance // 64 + width += glyph.width height = max_ascent + max_descent return (width, height, max_descent) @@ -244,7 +246,7 @@ def render_text(self, text, width=None, height=None, baseline=None, center=False if width > new_width: x_offset = (width - new_width) // 2 if height > new_height: - y_offset = (height - new_height) // 2 + y_offset = (height - (new_height - new_baseline)) // 2 - new_baseline x = 0 previous_char = None From 06d1307af774c08b296f4c392654e034662b27a7 Mon Sep 17 00:00:00 2001 From: Joshua Wise Date: Tue, 8 Jul 2025 03:17:07 -0400 Subject: [PATCH 3/5] be able to render multiple lines, good enough for me --- brother_pt/__main__.py | 37 ++++++++++++++++++++++++++++++++----- brother_pt/font.py | 24 +++++++++++++++--------- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/brother_pt/__main__.py b/brother_pt/__main__.py index 00c9493..c2ce74a 100644 --- a/brother_pt/__main__.py +++ b/brother_pt/__main__.py @@ -96,8 +96,35 @@ def do_text(args): found_printer = BrotherPt(printers[0].serial_number) required_height = MediaWidthToTapeMargin.to_print_width(found_printer.media_width) - font = Font(pattern = args.font, size = args.size) - image = font.render_text(args.text, height = required_height, center = True).pixels + FONT_DEFAULTS = [ + ( "Helsinki Narrow:style=Bold", 40, 6 ), + ( "Helsinki", 22, 8 ), + ( "Helsinki", 22, 4 ), + + ] + baseline_spacing = 4 + + fontpattern = args.font + fontsize = args.size + if not fontpattern or not fontsize: + # Try to pick some sensible defaults. + nlines = 1 + print(f"{nlines} lines on media size {required_height} px") + for fontpattern, fontsize, baseline_spacing in FONT_DEFAULTS: + font = Font(pattern = fontpattern, size = fontsize) + totheight = 0 + for t in args.text: + if totheight: + totheight += baseline_spacing + w,h,baseline = font.text_dimensions(t) + totheight += h + print(f"{fontpattern} @ {fontsize} is {totheight} px") + if totheight <= required_height: + print("that fits, good enough for me") + break + else: + font = Font(pattern = fontpattern, size = fontsize) + image = font.render_texts(args.text, height = required_height, vcenter = True, hcenter = False, spacing = baseline_spacing).pixels # Margin check margin = args.margin @@ -168,10 +195,10 @@ def cli(): text_menu = subparsers.add_parser('text', help="Print some text") text_menu.add_argument("-m", "--margin", type=int, default=50, help="Print margin in dots.") - text_menu.add_argument("-f", "--font", type=str, default="Arial", help="Font face (ok, actually, fontconfig pattern)") - text_menu.add_argument("-s", "--size", type=int, default=24, help="Font size") + text_menu.add_argument("-f", "--font", type=str, default=None, help="Font face (ok, actually, fontconfig pattern)") + text_menu.add_argument("-s", "--size", type=int, default=None, help="Font size") text_menu.add_argument("--preview", action='store_true', help="just show a preview, don't print a label") - text_menu.add_argument("text", type=str, help="Text to print") + text_menu.add_argument("text", nargs='+', help="Text to print") text_menu.set_defaults(cmd='text') args = parser.parse_args() diff --git a/brother_pt/font.py b/brother_pt/font.py index 350d4cf..2dd6bf1 100644 --- a/brother_pt/font.py +++ b/brother_pt/font.py @@ -212,7 +212,7 @@ def text_dimensions(self, text): # For each character in the text string we get the glyph # and update the overall dimensions of the resulting bitmap. for info, pos in zip(hb_buf.glyph_infos, hb_buf.glyph_positions): - print(self.harfbuzz.hbfont.glyph_to_string(info.codepoint), info.cluster, pos.x_advance, pos.x_offset, pos.y_offset) + # print(self.harfbuzz.hbfont.glyph_to_string(info.codepoint), info.cluster, pos.x_advance, pos.x_offset, pos.y_offset) glyph = self.glyph_for_glyphid(info.codepoint) max_ascent = max(max_ascent, glyph.ascent) max_descent = max(max_descent, glyph.descent) @@ -267,24 +267,28 @@ def render_text(self, text, width=None, height=None, baseline=None, center=False return outbuffer - def render_texts(self, texts, width=None, height=None, baseline=None, center=True, spacing=2): + def render_texts(self, texts, width=None, height=None, baseline=None, vcenter=True, hcenter = True, spacing=2): outbuffer = None + last_baseline = 0 for text in texts: + # save the baseline for later, because we want to V-center from + # top ascender to bottom baseline (!) + new_width, new_height, last_baseline = self.text_dimensions(text) text_buffer = self.render_text(text, baseline=baseline) if outbuffer: cur_height = outbuffer.height new_buffer = Bitmap(max(text_buffer.width, outbuffer.width), text_buffer.height + cur_height + spacing) - if center: + if hcenter: x_offset = (new_buffer.width - outbuffer.width) // 2 else: x_offset = 0 new_buffer.bitblt(outbuffer, x_offset, 0) outbuffer = new_buffer - if center: + if hcenter: x_offset = (outbuffer.width - text_buffer.width) // 2 else: x_offset = 0 @@ -294,12 +298,14 @@ def render_texts(self, texts, width=None, height=None, baseline=None, center=Tru width = width or outbuffer.width height = height or outbuffer.height - if center: + if vcenter: + y_offset = (height - (outbuffer.height - last_baseline)) // 2 + else: + y_offset = 0 + if hcenter: x_offset = (width - outbuffer.width) // 2 - y_offset = (height - outbuffer.height) // 2 else: x_offset = 0 - y_offset = 0 final_buffer = Bitmap(width, height) final_buffer.bitblt(outbuffer, x_offset, y_offset) @@ -323,8 +329,8 @@ def render_texts(self, texts, width=None, height=None, baseline=None, center=Tru print(repr(fnt.render_text('hello,', 64, 32, 2, center=True))) # Choosing the baseline correctly - print(repr(fnt.render_text('hello, .gjp', 64, 32, 2, center=True))) - print(repr(fnt.render_texts(['hello, world.', 'gjp'], 64, 48, 2, center=False, spacing=4))) + print(repr(fnt.render_texts(['hello, .gjp'], 64, 32, center=True))) + print(repr(fnt.render_texts(['hello, world.', 'gjp'], 128, 64, center=True, spacing=4))) print(fnt.render_text('e')) print(fnt.render_text('e').pixels) From c11fbee996f8845700924704492560334c71b41d Mon Sep 17 00:00:00 2001 From: Joshua Wise Date: Thu, 10 Jul 2025 02:27:20 -0400 Subject: [PATCH 4/5] add some authorship bits, add ability to select font by number --- brother_pt/__main__.py | 33 ++++++++++++++++++++++++++------- brother_pt/font.py | 1 + 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/brother_pt/__main__.py b/brother_pt/__main__.py index c2ce74a..b1768bc 100644 --- a/brother_pt/__main__.py +++ b/brother_pt/__main__.py @@ -15,6 +15,8 @@ """ import argparse +import PIL.ImageOps + from brother_pt import VERSION from .printer import * from .cmd import * @@ -100,13 +102,26 @@ def do_text(args): ( "Helsinki Narrow:style=Bold", 40, 6 ), ( "Helsinki", 22, 8 ), ( "Helsinki", 22, 4 ), - ] baseline_spacing = 4 - + fontpattern = args.font fontsize = args.size - if not fontpattern or not fontsize: + if args.font and args.font.isdigit(): + # The font is a number -- so we choose it as an index into + # FONT_DEFAULTS. + fontpattern, fontsize, baseline_spacing = FONT_DEFAULTS[int(args.font)] + font = Font(pattern = fontpattern, size = fontsize) + totheight = 0 + for t in args.text: + if totheight: + totheight += baseline_spacing + w,h,baseline = font.text_dimensions(t) + totheight += h + if totheight > required_height: + print(f"text does not fit on label with font {args.font} -- total height is {totheight} px, but label is {required_height} px!") + return 1 + elif not fontpattern or not fontsize: # Try to pick some sensible defaults. nlines = 1 print(f"{nlines} lines on media size {required_height} px") @@ -133,12 +148,16 @@ def do_text(args): "cutting length will be extended" % (image.width, margin, MINIMUM_TAPE_POINTS)) margin = MINIMUM_TAPE_POINTS - image.width - # Raster image - data = raster_image(image, found_printer.media_width) if args.preview: - image.show() + w,h = image.size + im2 = Image.new('L', (w + margin * 2, h), 0) + im2.paste(image, (margin, 0)) + PIL.ImageOps.invert(im2).show() return + # Raster image + data = raster_image(image, found_printer.media_width) + found_printer.print_data(data, margin) return 0 @@ -195,7 +214,7 @@ def cli(): text_menu = subparsers.add_parser('text', help="Print some text") text_menu.add_argument("-m", "--margin", type=int, default=50, help="Print margin in dots.") - text_menu.add_argument("-f", "--font", type=str, default=None, help="Font face (ok, actually, fontconfig pattern)") + text_menu.add_argument("-f", "--font", type=str, default=None, help="Font face, or a number specifying an index into the default table of fonts") text_menu.add_argument("-s", "--size", type=int, default=None, help="Font size") text_menu.add_argument("--preview", action='store_true', help="just show a preview, don't print a label") text_menu.add_argument("text", nargs='+', help="Text to print") diff --git a/brother_pt/font.py b/brother_pt/font.py index 2dd6bf1..1a26e1c 100644 --- a/brother_pt/font.py +++ b/brother_pt/font.py @@ -6,6 +6,7 @@ # http://dbader.org/blog/monochrome-font-rendering-with-freetype-and-python # Updated by Joseph Solomon 2016 +# Hacked to bits by Joshua Wise 2024 # The MIT License (MIT) # From 87dadf4b3e4d5f7fff2e862123702a834e6124d1 Mon Sep 17 00:00:00 2001 From: Joshua Wise Date: Thu, 10 Jul 2025 02:43:36 -0400 Subject: [PATCH 5/5] add some documentation about brother_pt text to the README --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index 7ff1943..fc071d1 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,50 @@ The most important command is the `print` command and here is its CLI signature: --margin Print margin --help Show this message and exit. +## Printing text labels + +Some people may wish to automatically print text on their labels, rather +than having to manually generate images first. For this, `brother_pt` has +the `text` command, which takes one or more lines of text to be printed as +parameters (be sure to quote them in your shell!). Consider something along +the lines of: + +``` +$ brother_pt text --preview "This is a larger label, rendered to your screen only." +$ brother_pt text "This is a larger label." +$ brother_pt text -f 1 "This is a single line in a smaller font." +$ brother_pt text "Here is one line" "and here is a second line." +$ brother_pt text "A label printer" "prints three lines at a time, but" "only haiku." +``` + +You can also specify a font with `--font` and `--size` (both must be +specified; `--font` takes a fontconfig-style pattern; consider something +along the lines of `--font "Comic Sans MS:style=Bold" --size 40` to show +that you are serious about your labels.) + +### Default fonts + +The default fonts selected are those distributed with the P-Touch software +-- in particular, the Helsinki font family, which seems to be hinted well +for black and white rendering on a P-Touch printer. To install these +fonts, either [install the latest P-Touch Editor software on your +Mac](https://www.brother-usa.com/ptouch/ptouch-label-editor-software), or if +on Linux, download the Windows version of the software and extract the fonts +with something like: + +``` +$ cabextract pew67001.exe ptedit6.msi # grab the installer bundle out of the InstallShield archive +$ msiextract ptedit6.msi # unpack the installer bundle TO THE CURRENT DIRECTORY +$ mv .\:Fonts/ Fonts +$ mv ./Program\ Files/Brother/P-touch\ Editor/6/Fonts/* Fonts/ +$ cp Fonts/BRHE* ~/.local/share/fonts/ # install at least the Helsinki font set +$ fc-cache -f -v # update fontconfig +``` + ## Author * Thomas Reidemeister + * Text rendering support was contributed by Joshua Wise ## Contributing