""" Continuous-tape label rendering for the Brother VC-500W. Sheet labels (Avery etc.) are laid out in label_render.py as an HTML page. A tape printer is a different animal: one label at a time, fixed width, and the length is however long the content needs to be. So these are rendered as images at the printer's native resolution and sent to it over IPP. VC-500W facts this is built around: * 313 dpi, full colour, ZINK (no ink) * CZ tape in 9, 12, 19, 25 and 50mm widths * up to about 17 inches (431mm) per label * AirPrint, so it speaks IPP on port 631 THE HOUSE LABEL SIZE -------------------- Drawer labels are a fixed physical size, not "however long the text is": one part in the drawer 40 x 15 mm two parts in the drawer 40 x 20 mm They are laid ACROSS the tape - the 40mm spans the tape width, so each label only eats 15mm (or 20mm) of tape length. That needs 50mm tape, and it is about 2.7x cheaper than running the 40mm along the feed: 100 standard labels come to 1.5m of tape instead of 4m. Cut across the tape between labels and you get a 40 x 15mm sticker. "Fit tape width" keeps the older behaviour - the label is as long as its content needs and fills the tape width - for anyone who wants it. Created by: Jess Rogerson (yelling commands at Claude.AI) """ import io import logging import os from dataclasses import dataclass, field from typing import List, Optional from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) DPI = 313 MAX_LENGTH_MM = 430.0 MIN_LENGTH_MM = 12.0 def mm_to_px(mm, dpi=DPI): return max(1, int(round(mm / 25.4 * dpi))) def px_to_mm(px, dpi=DPI): return px / dpi * 25.4 @dataclass class TapePreset: key: str name: str tape_mm: float printable_mm: float note: str = '' @property def printable_px(self): return mm_to_px(self.printable_mm) # Brother CZ tape widths. printable_mm leaves a hair of margin so a slightly # misaligned cassette doesn't clip the text; raise it toward tape_mm from # the settings page if you want true edge to edge. TAPE_PRESETS = { p.key: p for p in [ TapePreset('cz9', 'CZ tape 9mm', 9.0, 8.0, 'Too narrow for the 40mm house label.'), TapePreset('cz12', 'CZ tape 12mm', 12.0, 11.0, 'Too narrow for the 40mm house label.'), TapePreset('cz19', 'CZ tape 19mm', 19.0, 17.5, 'Too narrow for the 40mm house label.'), TapePreset('cz25', 'CZ tape 25mm', 25.0, 23.5, 'Too narrow for the 40mm house label.'), TapePreset('cz50', 'CZ tape 50mm', 50.0, 48.0, 'The one to use. Fits the 40mm label across the tape.'), ] } DEFAULT_TAPE = 'cz50' # ---------------------------------------------------------- label sizing LABEL_WIDTH_MM = 40.0 ONE_PART_HEIGHT_MM = 15.0 TWO_PART_HEIGHT_MM = 20.0 # Three or more part types in one drawer is not a size you asked for, so # rather than silently dropping parts the label grows by this much each. EXTRA_PART_HEIGHT_MM = 5.0 SIZE_MODES = [ ('auto', '40 x 15mm, or 40 x 20mm for a shared drawer'), ('standard', 'Always 40 x 15mm'), ('double', 'Always 40 x 20mm'), ('fit', 'Fit the tape width (length follows the content)'), ] DEFAULT_SIZE_MODE = 'auto' FIXED_MODES = ('auto', 'standard', 'double') # The title may take this share of the row height before Text size is even # applied. Generous on purpose: these are read at arm's length in a shed. TITLE_MAX_SHARE = 0.55 def label_height_mm(part_count, size_mode=DEFAULT_SIZE_MODE): """The physical height of a label holding this many part types.""" if size_mode == 'standard': return ONE_PART_HEIGHT_MM if size_mode == 'double': return TWO_PART_HEIGHT_MM if part_count <= 1: return ONE_PART_HEIGHT_MM if part_count == 2: return TWO_PART_HEIGHT_MM return TWO_PART_HEIGHT_MM + EXTRA_PART_HEIGHT_MM * (part_count - 2) def parse_group_spec(spec, labels): """ Turn "1,2|3" into [[label1, label2], [label3]]. This is how the page says "put these parts on one label" explicitly, which matters for customs and MOC drawers where the parts have nothing in common for auto-grouping to latch onto. Unknown or repeated ids are ignored, and anything the spec never mentions keeps a label of its own, so a stale spec can never silently drop a part. """ by_id = {label.id: label for label in labels} groups = [] used = set() for chunk in (spec or '').split('|'): group = [] for value in chunk.split(','): if not value.strip().isdigit(): continue wanted = int(value) if wanted in by_id and wanted not in used: group.append(by_id[wanted]) used.add(wanted) if group: groups.append(group) for label in labels: if label.id not in used: groups.append([label]) return groups def spec_from_groups(groups): """The inverse: [[l1,l2],[l3]] -> '1,2|3'.""" return '|'.join(','.join(str(l.id) for l in group) for group in groups) def group_by_drawer(labels, enabled=True): """ One physical label per drawer. Labels sharing a location are the same drawer, so they go on one taller label. A label with no location can't be grouped - it gets its own. Order is preserved: a group sits where its first member was. """ if not enabled: return [[label] for label in labels] groups = [] by_location = {} for label in labels: key = (label.location or '').strip().lower() if not key: groups.append([label]) continue if key in by_location: by_location[key].append(label) else: group = [label] by_location[key] = group groups.append(group) return groups # ------------------------------------------------------------------ fonts FONT_CANDIDATES = { False: [ # regular 'C:\\Windows\\Fonts\\segoeui.ttf', 'C:\\Windows\\Fonts\\arial.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', '/Library/Fonts/Arial.ttf', ], True: [ # bold 'C:\\Windows\\Fonts\\segoeuib.ttf', 'C:\\Windows\\Fonts\\arialbd.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', '/Library/Fonts/Arial Bold.ttf', ], } _font_cache = {} def load_font(size_px, bold=False): """Best available sans font at this pixel size. Never raises.""" key = (int(size_px), bool(bold)) if key in _font_cache: return _font_cache[key] for path in FONT_CANDIDATES[bool(bold)]: if os.path.exists(path): try: font = ImageFont.truetype(path, int(size_px)) _font_cache[key] = font return font except Exception: continue # Pillow's built-in bitmap font: ugly at 313dpi but never fails. font = ImageFont.load_default() _font_cache[key] = font return font # ---------------------------------------------------------------- options DEFAULT_TAPE_FIELDS = ['image', 'name', 'dimensions', 'part_number', 'accent'] # Hidden marker every options form submits. Its presence is how we tell # "submitted with the box unticked" from "arrived here with no options at # all" - an unticked checkbox sends nothing, so without this they look # identical and a default-on box can never be turned off. FORM_MARKER = '_opts' @dataclass class TapeOptions: tape: str = DEFAULT_TAPE size_mode: str = DEFAULT_SIZE_MODE # Combine labels that share a drawer onto one taller label. group_drawers: bool = True fields: List[str] = field( default_factory=lambda: list(DEFAULT_TAPE_FIELDS)) # Fixed sizes always lie across the tape - that is the point of them. # This only applies to 'fit' mode. rotate: bool = True copies: int = 1 # 'fit' mode only: a fixed length in mm instead of sizing to content. fixed_length_mm: Optional[float] = None padding_mm: float = 1.5 font_scale: float = 1.0 image_percent: int = 30 align: str = 'left' # left | center outline: bool = False # hairline box, for checking alignment @property def is_fixed(self): return self.size_mode in FIXED_MODES def get_tape(key): return TAPE_PRESETS.get(key or DEFAULT_TAPE, TAPE_PRESETS[DEFAULT_TAPE]) def tape_fits_label(tape): """Can the 40mm label lie across this tape?""" return get_tape(tape).printable_mm >= LABEL_WIDTH_MM def options_from_form(form, rotate_default=True, size_default=None, tape_default=None): """ Build TapeOptions from request.form / request.args. An unticked checkbox is simply absent from a form submission - the browser sends nothing for it. So "rotate unticked" and "rotate never mentioned" arrive here looking exactly the same, and a box that defaults to on could never be turned off. FORM_MARKER is a hidden field the options form always sends: when it is present we know every checkbox was on screen, so absent means the user unticked it. The *_default arguments apply only when the form was NOT submitted (a fresh arrival), so saved site-wide preferences are honoured there without ever overriding an explicit choice on the page. """ submitted = FORM_MARKER in form def number(key, default, cast=float): try: return cast(form.get(key, default)) except (TypeError, ValueError): return default def flag(key, default=False): if key in form: return str(form.get(key, '')).lower() in ('1', 'true', 'on', 'yes') # Submitted without it: the user unticked it. Not submitted: default. return False if submitted else default fields = list(form.getlist('fields')) if hasattr(form, 'getlist') else [] if not fields and not submitted: fields = list(DEFAULT_TAPE_FIELDS) length = (form.get('fixed_length_mm') or '').strip() if hasattr(form, 'get') else '' try: fixed = float(length) if length else None except ValueError: fixed = None valid_modes = {key for key, _ in SIZE_MODES} size_mode = form.get('size_mode') or size_default or DEFAULT_SIZE_MODE if size_mode not in valid_modes: size_mode = DEFAULT_SIZE_MODE return TapeOptions( tape=form.get('tape') or tape_default or DEFAULT_TAPE, size_mode=size_mode, group_drawers=flag('group_drawers', True), fields=fields, rotate=flag('rotate', rotate_default), copies=max(1, min(99, number('copies', 1, int))), fixed_length_mm=fixed, padding_mm=max(0.0, min(10.0, number('padding_mm', 1.5))), font_scale=max(0.5, min(3.0, number('font_scale', 1.0))), image_percent=max(0, min(60, number('image_percent', 30, int))), align=form.get('align') or 'left', outline=flag('outline', False), ) # ------------------------------------------------------------ text pieces def _label_lines(label, fields): """The text that goes on the tape, as (text, weight) pairs.""" lines = [] if 'name' in fields: title = (label.display_title if 'dimensions' in fields else label.name) if title: lines.append((title, 'title')) elif 'dimensions' in fields and label.dimensions: lines.append((label.dimensions, 'title')) if 'colour' in fields and label.colour_name: lines.append((label.colour_name, 'sub')) if 'category' in fields and label.category: lines.append((label.category.full_name, 'sub')) meta = [] if 'part_number' in fields and label.part_number: meta.append(label.part_number) if 'alt_numbers' in fields and label.alt_part_numbers: meta.append(label.alt_part_numbers) if 'category_code' in fields and label.category and label.category.display_code: meta.append(label.category.display_code) if meta: lines.append((' \u00b7 '.join(meta), 'meta')) if 'location' in fields and label.location: lines.append((label.location, 'meta')) return lines def _text_width(draw, text, font): try: box = draw.textbbox((0, 0), text, font=font) return box[2] - box[0] except Exception: return draw.textlength(text, font=font) def _fit_text(draw, text, font, available): """Trim with an ellipsis until it fits.""" shown = text while _text_width(draw, shown, font) > available and len(shown) > 2: shown = shown[:-2] if shown != text and len(shown) > 1: shown = shown[:-1] + '\u2026' return shown def wrap_text(draw, text, font, available): """Greedy word wrap. A word longer than the line gets its own line.""" words = (text or '').split() if not words: return [] lines = [words[0]] for word in words[1:]: trial = lines[-1] + ' ' + word if _text_width(draw, trial, font) <= available: lines[-1] = trial else: lines.append(word) return lines def _title_size_for(draw, text, available, inner_h, padding, options): """What size the title would come out at in this much room.""" if not text or available <= 0: return 0 _font, _lines, _h, size = layout_title( draw, text, available, inner_h - 2 * padding, inner_h * TITLE_MAX_SHARE * options.font_scale, inner_h * 0.13, bold=True) return size def layout_title(draw, text, available, max_h, max_px, min_px, bold=True, max_lines=2): """ Largest type at which the title fits the box, wrapping if that helps. "Plate, Modified with Clip" will not fit one line of a 40mm label at a readable size, but it fits two comfortably. Prefer wrapping over shrinking, and shrinking over truncating - the whole name is the point of the label. """ size = int(max_px) floor = max(6, int(min_px)) best = None while size >= floor: font = load_font(size, bold=bold) lines = wrap_text(draw, text, font, available) if lines and len(lines) <= max_lines: widest = max(_text_width(draw, line, font) for line in lines) height = len(lines) * size * 1.12 if widest <= available and height <= max_h: return font, lines, height, size if best is None: best = (font, lines, height, size) size -= 1 font = load_font(floor, bold=bold) return font, [text], floor * 1.12, floor def fit_font(draw, text, available, max_px, min_px, bold=False): """ The largest font size at which `text` still fits in `available`. A 40mm label has very little room once the artwork is on it, and part names vary from "Tile" to "Plate, Modified with Clip". Shrinking the type keeps the whole name readable; truncating it to "Plate, Mod..." does not. Below min_px it stops shrinking and the caller ellipsises. """ size = int(max_px) floor = max(6, int(min_px)) while size > floor: font = load_font(size, bold=bold) if _text_width(draw, text, font) <= available: return font, size size -= 1 return load_font(floor, bold=bold), floor def _load_art(label, upload_folder, box_px): if not label.image: return None path = os.path.join(upload_folder, label.image.replace('/', os.sep)) if not os.path.exists(path): return None try: art = Image.open(path).convert('RGBA') art.thumbnail((box_px, box_px), Image.LANCZOS) return art except Exception as exc: logger.warning('Could not load part image %s: %s', path, exc) return None # -------------------------------------------------------- fixed-size draw def render_fixed(labels, options, upload_folder): """ One drawer label at the house size, drawn flat and reading normally: 40mm wide, 15mm tall for one part, 20mm for two, taller beyond that. `labels` is every part type in that drawer - one row each. """ # Whatever is handed in gets drawn. Deciding how many parts belong on # one label is plan_groups' job - doing it here too once meant a part # was quietly dropped instead of getting a label of its own. labels = list(labels) or [] rows = max(1, len(labels)) width_px = mm_to_px(LABEL_WIDTH_MM) height_mm = label_height_mm(len(labels), options.size_mode) height_px = mm_to_px(height_mm) canvas = Image.new('RGB', (width_px, height_px), 'white') draw = ImageDraw.Draw(canvas) if options.outline: draw.rectangle([0, 0, width_px - 1, height_px - 1], outline=(190, 190, 190), width=max(1, mm_to_px(0.2))) fields = set(options.fields) # Deliberately tighter than the fit-tape padding: 1.5mm top and bottom # would eat a third of a two-part label's 10mm row. padding = mm_to_px(min(options.padding_mm, 0.8)) row_h = height_px / rows for index, label in enumerate(labels): top = int(index * row_h) bottom = int((index + 1) * row_h) inner_h = bottom - top # A hairline between parts so the two halves read as separate. if index: draw.line([(padding, top), (width_px - padding, top)], fill=(205, 205, 205), width=max(1, mm_to_px(0.15))) cursor_x = padding if 'accent' in fields and label.category: accent_w = mm_to_px(1.2) colour = label.category.accent_colour() try: rgb = tuple(int(colour.lstrip('#')[i:i + 2], 16) for i in (0, 2, 4)) except Exception: rgb = (209, 16, 19) draw.rectangle([cursor_x, top + padding, cursor_x + accent_w, bottom - padding], fill=rgb) cursor_x += accent_w + padding lines = _label_lines(label, fields) title = next((t for t, w in lines if w == 'title'), None) rest = [t for t, w in lines if w != 'title'] meta_text = ' \u00b7 '.join(rest) if rest else None # Text beats artwork. The picture is a hint; the name is the label. # Try the full-size image first and give room back to the type until # the title reaches the size the Text size setting asked for - that # is what makes turning Text size up actually do something on a # label this small. text_left = cursor_x art = None wanted_px = inner_h * 0.34 * options.font_scale base_box = min(inner_h - 2 * padding, int(width_px * options.image_percent / 100.0)) if 'image' in fields and options.image_percent and label.image: for share in (1.0, 0.75, 0.5, 0.0): candidate = _load_art(label, upload_folder, int(base_box * share)) if share else None used = (candidate.width + padding) if candidate else 0 room = width_px - cursor_x - used - padding probe_size = _title_size_for(draw, title, room, inner_h, padding, options) if title else 0 art = candidate text_left = cursor_x + used if not title or probe_size >= wanted_px or share == 0.0: break if art is not None: canvas.paste(art, (cursor_x, top + (inner_h - art.height) // 2), art) cursor_x = text_left available = width_px - cursor_x - padding # The name comes first and takes what it needs; the meta line gets # the leftover. Sizing the meta line first (and letting Text size # inflate it) pushed the part number off the bottom of the label and # squeezed the name smaller the higher you turned the setting. room = inner_h - 2 * padding reserve = min(room * 0.30, inner_h * 0.26) if meta_text else 0 title_font = title_lines = None title_h = 0 title_px = 0 if title: # The floor stays put as Text size rises: the setting lifts the # ceiling, and the fitter takes the largest size the whole name # fits at. Raising the floor too meant a long name could not # shrink far enough to wrap and got truncated instead. title_font, title_lines, title_h, title_px = layout_title( draw, title, available, room - reserve, inner_h * TITLE_MAX_SHARE * options.font_scale, inner_h * 0.13, bold=True) meta_font = meta_px = None meta_h = 0 leftover = room - title_h if meta_text and leftover > mm_to_px(1.2): meta_font, meta_px = fit_font( draw, meta_text, available, min(inner_h * 0.26 * options.font_scale, leftover / 1.15), min(inner_h * 0.15, leftover / 1.15), bold=False) meta_h = meta_px * 1.15 block_h = title_h + meta_h y = top + max(padding, (inner_h - block_h) / 2) if title_lines: for line in title_lines: shown = _fit_text(draw, line, title_font, available) x = cursor_x if options.align == 'center': x += max(0, (available - _text_width(draw, shown, title_font)) / 2) draw.text((x, y), shown, font=title_font, fill=(0, 0, 0)) y += title_px * 1.12 if meta_text: shown = _fit_text(draw, meta_text, meta_font, available) x = cursor_x if options.align == 'center': x += max(0, (available - _text_width(draw, shown, meta_font)) / 2) draw.text((x, y), shown, font=meta_font, fill=(70, 70, 70)) return canvas # ------------------------------------------------------- fit-tape drawing def render_fit(label, options, upload_folder): """ The older behaviour: the label fills the tape width and runs as long as its content needs. Drawn flat, reading left to right. """ tape = get_tape(options.tape) height = tape.printable_px padding = mm_to_px(options.padding_mm) fields = set(options.fields) lines = _label_lines(label, fields) base = height * 0.34 * options.font_scale if len(lines) > 2: base *= 0.78 sizes = { 'title': max(8, int(base)), 'sub': max(7, int(base * 0.72)), 'meta': max(6, int(base * 0.62)), } fonts = { 'title': load_font(sizes['title'], bold=True), 'sub': load_font(sizes['sub'], bold=False), 'meta': load_font(sizes['meta'], bold=False), } art = None art_width = 0 if 'image' in fields and options.image_percent: art = _load_art(label, upload_folder, height - 2 * padding) if art is not None: art_width = art.width + padding accent_width = mm_to_px(1.2) if ('accent' in fields and label.category) else 0 probe = ImageDraw.Draw(Image.new('RGB', (10, 10))) text_width = 0 for text, weight in lines: text_width = max(text_width, _text_width(probe, text, fonts[weight])) natural = accent_width + padding + art_width + text_width + 2 * padding if options.fixed_length_mm: width = mm_to_px(min(MAX_LENGTH_MM, max(MIN_LENGTH_MM, options.fixed_length_mm))) else: width = max(mm_to_px(MIN_LENGTH_MM), min(mm_to_px(MAX_LENGTH_MM), int(natural))) canvas = Image.new('RGB', (width, height), 'white') draw = ImageDraw.Draw(canvas) if options.outline: draw.rectangle([0, 0, width - 1, height - 1], outline=(190, 190, 190), width=max(1, mm_to_px(0.2))) cursor_x = 0 if accent_width: colour = label.category.accent_colour() try: rgb = tuple(int(colour.lstrip('#')[i:i + 2], 16) for i in (0, 2, 4)) except Exception: rgb = (209, 16, 19) draw.rectangle([0, 0, accent_width, height], fill=rgb) cursor_x = accent_width + padding if art is not None: canvas.paste(art, (cursor_x, (height - art.height) // 2), art) cursor_x += art.width + padding total_text = sum(sizes[w] * 1.22 for _, w in lines) y = max(padding, (height - total_text) / 2) available = width - cursor_x - padding for text, weight in lines: font = fonts[weight] shown = _fit_text(draw, text, font, available) x = cursor_x if options.align == 'center': x = cursor_x + max(0, (available - _text_width(draw, shown, font)) / 2) draw.text((x, y), shown, font=font, fill=(0, 0, 0)) y += sizes[weight] * 1.22 return canvas # --------------------------------------------------------- tape composing def compose_for_tape(flat, options): """ Place a flat label onto the tape raster. Convention: the returned image's WIDTH is the across-tape dimension and its HEIGHT is how much tape length the label consumes. Fixed sizes always lie across the tape - the 40mm spans the tape width, which is the whole point of them - so the flat image goes on as-is, centred, and each label only costs 15mm of tape. In 'fit' mode the rotate option still chooses between reading along the tape or across it. The raster is the full printable tape width even though the label is 40mm: AirPrint scales a job to the media width, so sending a 40mm-wide image would be stretched to 48 and print oversize. Full width at 1:1 keeps the printed label exactly 40mm. """ tape = get_tape(options.tape) tape_px = tape.printable_px if options.is_fixed: placed = flat # Too narrow a tape would clip the label; scale to fit and let the # caller warn, rather than printing something cut off. if placed.width > tape_px: ratio = tape_px / placed.width placed = placed.resize( (tape_px, max(1, int(placed.height * ratio))), Image.LANCZOS) canvas = Image.new('RGB', (tape_px, placed.height), 'white') canvas.paste(placed, ((tape_px - placed.width) // 2, 0)) return canvas # 'fit' mode: rotate so the label reads along the tape as it feeds. return flat.rotate(90, expand=True) if options.rotate else flat def render_group(labels, options, upload_folder): """A finished tape raster for one drawer (one or more part types).""" labels = list(labels) if options.is_fixed: flat = render_fixed(labels, options, upload_folder) else: flat = render_fit(labels[0], options, upload_folder) return compose_for_tape(flat, options) def render_label(label, options, upload_folder): """Single-label convenience wrapper (previews, one-off prints).""" group = label if isinstance(label, (list, tuple)) else [label] return render_group(group, options, upload_folder) def plan_groups(labels, options, group_spec=None): """ How the selection turns into physical labels. Returns a list of dicts: the parts on each label, its size in mm, and whether it will fit the loaded tape. An explicit group_spec from the page wins over auto-grouping - that is how you combine parts by hand for a custom drawer. """ tape = get_tape(options.tape) if group_spec: groups = parse_group_spec(group_spec, labels) else: groups = group_by_drawer( labels, enabled=options.group_drawers and options.is_fixed) # How many part types one label may carry. 'standard' is a one-part # label, so a drawer with two parts gets two labels rather than losing # one; 'double' spills a third part onto a second label. per_label = {'standard': 1, 'double': 2}.get(options.size_mode) # Groups the user built by hand are never broken up: asking for these # parts together outranks the size setting, so the label grows instead. explicit = bool(group_spec) plan = [] for group in groups: chunks = [group] if options.is_fixed and per_label and not explicit: chunks = [group[i:i + per_label] for i in range(0, len(group), per_label)] or [[]] for chunk in chunks: if not chunk: continue if options.is_fixed: width_mm = LABEL_WIDTH_MM mode = options.size_mode if explicit and per_label and len(chunk) > per_label: mode = 'auto' height_mm = label_height_mm(len(chunk), mode) fits = tape.printable_mm >= width_mm else: width_mm = height_mm = None fits = True plan.append({ 'labels': chunk, 'width_mm': width_mm, 'height_mm': height_mm, 'fits': fits, 'drawer': (chunk[0].location or '').strip(), }) return plan def tape_used_mm(plan, copies=1): """Total tape length a print run will consume, in mm.""" total = 0.0 for item in plan: total += (item['height_mm'] or 0) * copies return total # --------------------------------------------------------------- outputs def image_to_bytes(image, mime_type): """Serialise a rendered label in whatever format the printer accepts.""" buffer = io.BytesIO() if mime_type == 'image/jpeg': image.convert('RGB').save(buffer, 'JPEG', quality=95, dpi=(DPI, DPI)) elif mime_type == 'image/png': image.save(buffer, 'PNG', dpi=(DPI, DPI)) elif mime_type == 'application/pdf': image.convert('RGB').save(buffer, 'PDF', resolution=DPI) else: raise ValueError(f'Cannot produce {mime_type}') buffer.seek(0) return buffer.getvalue() def render_batch_pdf(labels, options, upload_folder, group_spec=None): """Every label as one multi-page PDF, one tape label per page.""" plan = plan_groups(labels, options, group_spec) images = [] for item in plan: for _ in range(options.copies): images.append(render_group(item['labels'], options, upload_folder) .convert('RGB')) if not images: return None buffer = io.BytesIO() images[0].save(buffer, 'PDF', resolution=DPI, save_all=True, append_images=images[1:]) buffer.seek(0) return buffer def preview_png(labels, options, upload_folder, scale=11.0): """ A screen preview of one physical label, shown flat so it is readable. Scale is px per mm on screen, so a 40 x 15mm label previews at roughly 440 x 165 - close enough to actual size on a typical monitor to judge. """ group = labels if isinstance(labels, (list, tuple)) else [labels] if options.is_fixed: flat = render_fixed(group, options, upload_folder) else: saved = options.rotate options.rotate = False try: flat = render_fit(group[0], options, upload_folder) finally: options.rotate = saved target_w = max(1, int(px_to_mm(flat.width) * scale)) target_h = max(1, int(px_to_mm(flat.height) * scale)) preview = flat.resize((target_w, target_h), Image.LANCZOS) buffer = io.BytesIO() preview.save(buffer, 'PNG') buffer.seek(0) return buffer