# SPDX-FileCopyrightText: 2026 Blender Authors # # SPDX-License-Identifier: GPL-2.0-or-later """ Tests for the BLF (Blender Font) and VFont (3D Text) Python APIs. BLF tests cover the buffer and GPU rendering pipelines (--mode=BUFFER, --mode=GPU). VFont tests cover the 3D text object pipeline (--mode=VFONT). Usage: ./blender.bin --background --factory-startup --python tests/python/bl_pyapi_blf.py -- --mode=BUFFER ./blender.bin --background --factory-startup --python tests/python/bl_pyapi_blf.py -- --mode=VFONT To regenerate reference images: ./blender.bin ... -- --mode=ALL --generate To generate an HTML report on failure: ./blender.bin ... -- --mode=ALL --show-html /tmp/blf_report.html To run a single test class: ./blender.bin ... -- --mode=ALL -k TestCombiningKerning Adding Tests ============ Adding texts involves adding a class with a list of "cases", there are many examples in this file. When adding the test case, getting the proper framing (offset & size) can be a chore. To make this more straightforward - test cases may have prepare=True set, so they can print out framing which can be used as input. - Add a case with ``prepare=True`` and placeholder values:: CaseVFont( name="my_test", text="Hello", size=(0, 0), expected_dimensions=(0.0, 0.0), prepare=True, ) For generic tests, set ``prepare=True`` on the ``GenericParamsBuffer`` and/or ``GenericParamsVFont``. - Run the test to get proposed framing values:: ./blender.bin ... -- --mode=ALL -k my_test Output:: prepare my_test.buffer: expected_dimensions=(164, 53), size=(184, 73), position_offset=(10, 0), prepare my_test.vfont: expected_dimensions=(2.012345, 0.567890), size=(164, 60), position_offset=(6.7, 10.6), - Copy the values back, remove ``prepare=True``, and generate references:: ./blender.bin ... -- --mode=ALL --generate -k my_test """ from __future__ import annotations __all__ = ( "main", ) import argparse import contextlib import itertools import math import os import random import shutil import subprocess import sys import tempfile import unicodedata import unittest from collections.abc import Callable, Iterator from typing import ContextManager, NamedTuple, Self import blf # type: ignore[import-not-found] import bpy # type: ignore[import-not-found] import gpu # type: ignore[import-not-found] import gpu.matrix # type: ignore[import-not-found] import gpu.state # type: ignore[import-not-found] import imbuf # type: ignore[import-not-found] import mathutils # type: ignore[import-not-found] from gpu_extras.batch import batch_for_shader # type: ignore[import-not-found] # GPU initialization is deferred to `main()` so --mode=BUFFER doesn't pay for it. # When init fails and `WITHOUT_GPU` is set, GPU test methods skip cleanly. # ------------------------------------------------------------------------------ # Constants class GlobalsBuffer(NamedTuple): """Default constants for the BLF buffer/GPU pipeline.""" size: int = 72 # Set to e.g. 4 to scale up rendering for visual inspection. test_scale: int = 1 class GlobalsVFont(NamedTuple): """Default constants for the VFont (3D Text) pipeline.""" size: float = 1.0 line_spacing: float = 1.0 character_spacing: float = 1.0 # Render at 2x then scale down for basic anti-aliasing without heavy geometry. scale_oversample: int = 2 globals_buffer = GlobalsBuffer() globals_vfont = GlobalsVFont() del GlobalsBuffer, GlobalsVFont SOURCE_DIR: str = os.path.abspath(os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))) TEST_DIR: str = os.path.join(SOURCE_DIR, "tests", "files", "blenfont") RENDER_DIR_BUFFER: str = os.path.join(TEST_DIR, "buffer_renders") RENDER_DIR_VFONT: str = os.path.join(TEST_DIR, "vfont_renders") IDIFF_BIN: str = os.environ.get("IDIFF_BIN") or shutil.which("idiff") or "" FONT_PATH_REGULAR: str = os.path.join(TEST_DIR, "noto_fonts", "NotoSans-Regular.otf") FONT_PATH_ITALIC: str = os.path.join(TEST_DIR, "noto_fonts", "NotoSans-Italic.otf") FONT_PATH_BOLD: str = os.path.join(TEST_DIR, "noto_fonts", "NotoSans-Bold.otf") FONT_PATH_BOLD_ITALIC: str = os.path.join(TEST_DIR, "noto_fonts", "NotoSans-BoldItalic.otf") # Per-side padding (in pixels) around content in the final cropped reference image. PREPARE_MARGIN_PX: int = 10 # Per-side padding used in the over-sized scratch buffer during the prepare search. # Larger than the final margin so content has room to extend in any direction without # clipping, allowing accurate measurement of where it lands. PREPARE_SEARCH_MARGIN_PX: int = 200 # VFont units to pixels (at 1x scale). VFont rendering uses this * scale_oversample. VFONT_PPU: float = 72.0 # Buffer pixel format used by `render_text_buffer`: opaque-black RGBA. BUFFER_BYTES_PER_PIXEL: int = 4 BUFFER_CLEAR_PIXEL: bytes = b"\x00\x00\x00\xff" # ------------------------------------------------------------------------------ # Globals USE_GENERATE_TEST_DATA: bool = False # When set, verify each VFont-rendered image is correctly framed (no clipping, # no excessive margin). Enable via: `USE_TEST_BOUNDS_ERROR=1`. USE_TEST_BOUNDS_ERROR: bool = bool(os.environ.get("USE_TEST_BOUNDS_ERROR")) # Force prepare mode on every case, overriding per-case `prepare=True/False`. # Useful after changing fonts to re-derive framing values for all tests. USE_PREPARE_FORCE: bool = False # When True, VFont images are rendered via Cycles with an orthographic camera instead of the GPU triangle path. # Avoids `gpu.init()` so tests can run on headless build-bots (but is much slower). USE_VFONT_RENDER: bool = False VFONT_RENDER_DIR: str = "" IDIFF_FAIL: float = 0.004 IDIFF_FAIL_PERCENT: float = 0.0 SHOW_HTML: str = "" COMPARE_IMAGES: list["ComparedImage"] = [] OUTPUT_DIR: str = "" class VFontSet(NamedTuple): """The font variants used by VFont text objects.""" regular: bpy.types.VectorFont bold: bpy.types.VectorFont italic: bpy.types.VectorFont bold_italic: bpy.types.VectorFont FONTS_VFONT: VFontSet | None = None class VFontRenderContext: """ Persistent Cycles render environment for ``USE_VFONT_RENDER``. Amortises the per-render overhead that is constant across all tests: scene-property saves/restores, camera creation/destruction, and hiding the pre-existing scene objects. Created once before ``unittest.main()`` runs (alongside ``FONTS_VFONT``) and torn down in the same ``finally`` block. Each call to ``render_text_vfont_camera`` only needs to: - update the camera ``ortho_scale`` and ``location`` for the current size, - create/destroy the per-case text object and materials, - call ``bpy.ops.render.render(write_still=True)``. """ def __init__(self) -> None: scene = bpy.context.scene self._scene = scene self._prev_engine = scene.render.engine scene.render.engine = 'CYCLES' changes = [ (scene.render, "film_transparent", True), (scene.render.image_settings, "file_format", 'PNG'), (scene.render.image_settings, "color_mode", 'RGB'), (scene.render.image_settings, "color_depth", '8'), (scene.cycles, "samples", 1), (scene.cycles, "use_adaptive_sampling", False), (scene.cycles, "use_denoising", False), (scene.cycles, "max_bounces", 0), (scene.view_settings, "view_transform", 'Raw'), (scene.world.cycles, "sampling_method", 'NONE'), (scene.render, "dither_intensity", 0.0), ] self._prev = [(obj, attr, getattr(obj, attr)) for obj, attr, _ in changes] for obj, attr, val in changes: setattr(obj, attr, val) # Black world background - save/restore node value directly. world_bg = scene.world.node_tree.nodes["Background"] self._prev_world_color = tuple(world_bg.inputs[0].default_value) self._prev_world_strength = float(world_bg.inputs[1].default_value) world_bg.inputs[0].default_value = (0.0, 0.0, 0.0, 1.0) world_bg.inputs[1].default_value = 0.0 self._prev_hide_render: dict[str, bool] = { obj.name: obj.hide_render for obj in scene.objects } for obj in scene.objects: obj.hide_render = True assert all(obj.hide_render for obj in scene.objects), "Scene has visible objects after hide" # Persistent orthographic camera - only ortho_scale and location change per render. cam_data = bpy.data.cameras.new("_test_cam") cam_data.type = 'ORTHO' cam_data.sensor_fit = 'HORIZONTAL' self.cam_data: bpy.types.Camera = cam_data self.cam_obj = bpy.data.objects.new("_test_cam_obj", cam_data) scene.collection.objects.link(self.cam_obj) self.tmp_path = os.path.join(VFONT_RENDER_DIR, "render.png") for obj, attr, val in [ (scene, "camera", self.cam_obj), (scene.render, "filepath", self.tmp_path), ]: self._prev.append((obj, attr, getattr(obj, attr))) setattr(obj, attr, val) def __enter__(self) -> Self: return self def __exit__(self, *_: object) -> None: self.teardown() def teardown(self) -> None: """Destroy the camera and restore every saved scene property.""" scene = self._scene bpy.data.objects.remove(self.cam_obj) bpy.data.cameras.remove(self.cam_data) self._scene.render.engine = self._prev_engine for obj, attr, val in self._prev: setattr(obj, attr, val) world_bg = self._scene.world.node_tree.nodes["Background"] world_bg.inputs[0].default_value = self._prev_world_color world_bg.inputs[1].default_value = self._prev_world_strength for obj_name, was_hidden in self._prev_hide_render.items(): if obj_name in scene.objects: scene.objects[obj_name].hide_render = was_hidden VFONT_RENDER_CTX: VFontRenderContext | None = None # Test classes are registered here by TestImageComparison_MixIn.__init_subclass__ # and have their per-case test methods attached later by attach_test_methods(), # once main() has parsed --mode. This avoids needing to know the mode at import # time (when subclasses are defined). TEST_CLASSES: list[type["TestImageComparison_MixIn"]] = [] # Per-mode set of "kinds" of test method to generate. The BUFFER kind also runs # the dimensions check inline (as part of `_run_render_case`) since dimensions # don't depend on the rendering pipeline and BUFFER is the reference-generating # mode. CMake registers one test per mode (BUFFER, GPU, VFONT); ALL is a # manual-run convenience that runs every kind in a single invocation. MODE_KINDS: dict[str, tuple[str, ...]] = { 'BUFFER': ("buffer",), 'GPU': ("gpu",), 'VFONT': ("vfont",), 'ALL': ("buffer", "gpu", "vfont"), } def attach_test_methods(mode: str) -> None: """ Generate test__ methods on each registered test class. Called from main() after --mode is parsed. For each kind in the active mode, walks the matching case list on the class (``cases_buffer`` for buffer/gpu, ``cases_vfont`` for vfont) and skips kinds with no cases. """ def make_runner( case: CaseBuffer | CaseVFont, kind: str, ) -> Callable[[TestImageComparison_MixIn], None]: """Build a unittest test method that runs ``case`` for the given kind.""" if kind == "vfont": def runner(self: TestImageComparison_MixIn) -> None: self._run_case_vfont(case) # type: ignore[arg-type] else: def runner(self: TestImageComparison_MixIn) -> None: self._run_render_case(case, kind) # type: ignore[arg-type] return runner kinds = MODE_KINDS[mode] for cls in TEST_CLASSES: cases_buffer = cls.__dict__.get("cases_buffer", ()) cases_vfont = cls.__dict__.get("cases_vfont", ()) for kind in kinds: cases = cases_vfont if kind == "vfont" else cases_buffer for case in cases: method_name = "test_{:s}_{:s}".format(kind, case.name) if hasattr(cls, method_name): raise TypeError( "{:s}: generated test method '{:s}' collides with existing attribute".format( cls.__name__, method_name, ) ) runner = make_runner(case, kind) runner.__name__ = method_name runner.__qualname__ = "{:s}.{:s}".format(cls.__name__, method_name) runner.__doc__ = "{:s} render of {:s}{:s}".format(kind, cls.name_prefix, case.name) setattr(cls, method_name, runner) # ------------------------------------------------------------------------------ # Types # Collected data used to populate the HTML. class ComparedImage(NamedTuple): # Rendering kind: "buffer", "gpu", or "vfont". Each kind gets its own section in the HTML report. kind: str #: Test class name, used to group entries in the HTML report. group: str # Name, used for the `subTest` & output filename. name: str # The text being tested. text: str # "Golden" reference image, if we differ from this - the test fails. ref_path: str # The generated output to test against. test_path: str # Difference image from idiff (empty string if not generated). diff_path: str passed: bool # Text output from `idiff`. idiff_output: str # Data needed for a render test. # Large width sentinel for tests that want WORD_WRAP enabled (so newlines are honored as line # breaks) but with no realistic chance of a width-based wrap occurring. WRAP_WIDTH_LARGE: int = 99999 class CaseBuffer(NamedTuple): name: str text: str size: tuple[int, int] expected_dimensions: tuple[int, int] position_offset: tuple[int, int] font_size: int = globals_buffer.size # BLF word-wrap width. # - `-1` (the default) leaves WORD_WRAP disabled. # - `0` and positive : values are passed through to `blf.word_wrap`; # - use `WRAP_WIDTH_LARGE` to enable WORD_WRAP without a meaningful width-based break. wrap_width: int = -1 # When True, skip validation and print proposed values for size, # expected_dimensions, and position_offset. prepare: bool = False class StyleSpan(NamedTuple): bold: bool italic: bool underline: bool smallcaps: bool material: int kerning: float class TextFormatCompose: """ Builder for styled text, records per-character formatting for VFont. Example:: TextFormatCompose().style(bold=True).text("hello").style(bold=False).text(" world") """ def __init__(self) -> None: self._body: list[str] = [] self._spans: list[StyleSpan] = [] self._bold: bool = False self._italic: bool = False self._underline: bool = False self._smallcaps: bool = False self._material: int = 0 self._kerning: float = 0.0 def style( self, *, bold: bool | None = None, italic: bool | None = None, underline: bool | None = None, smallcaps: bool | None = None, material: int | None = None, kerning: float | None = None, ) -> TextFormatCompose: if bold is not None: self._bold = bold if italic is not None: self._italic = italic if underline is not None: self._underline = underline if smallcaps is not None: self._smallcaps = smallcaps if material is not None: self._material = material if kerning is not None: self._kerning = kerning return self def text(self, value: str) -> TextFormatCompose: span = StyleSpan( self._bold, self._italic, self._underline, self._smallcaps, self._material, self._kerning, ) for ch in value: self._body.append(ch) self._spans.append(span) return self @property def body(self) -> str: return "".join(self._body) @property def spans(self) -> list[StyleSpan]: return list(self._spans) class TextBox(NamedTuple): """ Mirror of :class:`bpy.types.TextBox` (every field a VFont text box exposes). Coordinates are in font units; ``y`` and ``height`` follow vfont's y-up convention, so a width-only box at the curve origin is ``TextBox(x=0, y=0, width=W, height=0)``. """ x: float = 0.0 y: float = 0.0 width: float = 0.0 height: float = 0.0 class CaseVFont(NamedTuple): name: str text: str | TextFormatCompose size: tuple[int, int] expected_dimensions: tuple[float, float] position_offset: tuple[float, float] = (0.0, 0.0) font_size: float = globals_vfont.size text_boxes: list[TextBox] | None = None overflow: str = 'NONE' align_x: str = 'LEFT' align_y: str = 'TOP_BASELINE' space_word: float = 1.0 space_line: float = 1.0 space_character: float = 1.0 geometry_offset: float = 0.0 geometry_extrude: float = 0.0 materials: list[tuple[float, float, float]] | None = None # Optional context-manager factory invoked after the text Object has been created # and before the depsgraph evaluates it. Receives the Object as its only argument # and must return a context manager: setup runs in __enter__, teardown in __exit__. with_context_fn: Callable[[bpy.types.Object], ContextManager[None]] | None = None # curve.resolution_u -- subdivision resolution of each glyph outline. resolution: int = 4 # When True, skip validation and print proposed values for size, # expected_dimensions, and position_offset. prepare: bool = False class GenericParamsBuffer(NamedTuple): """Per-pipeline parameters for the BLF buffer/gpu renderer.""" size: tuple[int, int] expected_dimensions: tuple[int, int] position_offset: tuple[int, int] = (10, 0) font_size: int = globals_buffer.size wrap_width: int = -1 prepare: bool = False class GenericParamsVFont(NamedTuple): """Per-pipeline parameters for the VFont (3D Text) renderer.""" size: tuple[int, int] expected_dimensions: tuple[float, float] position_offset: tuple[float, float] = (0.0, 0.0) font_size: float = globals_vfont.size prepare: bool = False class CaseGeneric(NamedTuple): """ Shared test case that produces both a CaseBuffer and a CaseVFont. Holds the test text once, plus per-pipeline params. Use ``case_generic_to_buffer`` / ``case_generic_to_vfont`` to convert. """ name: str text: str buffer: GenericParamsBuffer vfont: GenericParamsVFont def case_generic_to_buffer(cases: list[CaseGeneric]) -> list[CaseBuffer]: """Convert a list of CaseGeneric to CaseBuffer.""" return [CaseBuffer(name=c.name, text=c.text, **c.buffer._asdict()) for c in cases] def case_generic_to_vfont(cases: list[CaseGeneric]) -> list[CaseVFont]: """Convert a list of CaseGeneric to CaseVFont.""" return [CaseVFont(name=c.name, text=c.text, **c.vfont._asdict()) for c in cases] # ------------------------------------------------------------------------------ # Internal Utilities def generate_lorem_combining(word_count: int, seed: int) -> str: """Generate deterministic lorem ipsum text with combining characters.""" words = [ "lo\u0300rem", "i\u0301psum", "dolo\u0301r", "si\u0300t", "a\u0300met", "conse\u0301ctetur", "adipi\u0300scing", "e\u0300lit", "se\u0301d", "ei\u0300usmod", "tempo\u0301r", "incidi\u0301dunt", "labo\u0300re", "dolo\u0300re", "ma\u0301gna", "ali\u0300qua", ] rng = random.Random(seed) return " ".join(rng.choice(words) for _ in range(word_count)) def generate_stress_text(word_size: int = 8) -> str: """Generate a stress-test string from various Unicode blocks, grouped into words.""" codepoints: list[int] = [] # Latin block: U+0001 - U+00FF (skip null terminator). codepoints.extend(range(0x01, 0x100)) # Combining Diacriticals: U+0300 - U+036F. codepoints.extend(range(0x0300, 0x0370)) # Number Forms / Roman Numerals: U+2150 - U+218F. codepoints.extend(range(0x2150, 0x2190)) # CJK Unified Ideographs (small sample): U+4E00 - U+4E20. codepoints.extend(range(0x4E00, 0x4E20)) # Halfwidth / Fullwidth Forms (sample): U+FF01 - U+FF20. codepoints.extend(range(0xFF01, 0xFF20)) # Noncharacters: U+FDD0 - U+FDEF. codepoints.extend(range(0xFDD0, 0xFDF0)) # Supplementary plane (4-byte UTF-8): Emoji U+1F600 - U+1F610. codepoints.extend(range(0x1F600, 0x1F610)) # Mathematical Alphanumeric (sample): U+1D400 - U+1D410. codepoints.extend(range(0x1D400, 0x1D410)) # Build words of `word_size` characters separated by spaces. words = [] for i in range(0, len(codepoints), word_size): word = "".join(chr(c) for c in codepoints[i:i + word_size]) words.append(word) return " ".join(words) # Unicode canonical combining classes (no named constants in Python's `unicodedata`). COMBINING_CLASS_BELOW: int = 220 COMBINING_CLASS_ABOVE: int = 230 def generate_alphabet_combiners(combining_classes: set[int], *, line_count: int) -> str: """Generate ``AaBbCc...Zz`` cycling through combiners of the given classes. Each upper-lower pair uses the next combiner, wrapping around. The full cycle is repeated over ``line_count`` lines. """ combiners = [ chr(c) for c in range(0x0300, 0x0370) if unicodedata.combining(chr(c)) in combining_classes ] lines = [] for line in range(line_count): pairs = [] for i in range(26): cm = combiners[(line * 26 + i) % len(combiners)] pairs.append(chr(ord('A') + i) + cm + chr(ord('a') + i) + cm) lines.append("".join(pairs)) return "\n".join(lines) def generate_alphabet_above_and_below_combiners(*, line_count: int) -> str: """Generate ``AaBbCc...Zz`` with above combiners then below combiners.""" above = generate_alphabet_combiners({COMBINING_CLASS_ABOVE}, line_count=line_count) below = generate_alphabet_combiners({COMBINING_CLASS_BELOW}, line_count=line_count) return above + "\n\n" + below def generate_combining_stress(base: str, marks: str, count: int) -> str: """Generate a string with ``count`` combining marks stacked on ``base``.""" return base + (marks * count) def load_font_blf() -> int: """Load the test font into BLF and return the font handle.""" font_id: int = blf.load(FONT_PATH_REGULAR) if font_id < 0: raise RuntimeError("Could not load font: {:s}".format(FONT_PATH_REGULAR)) blf.size(font_id, globals_buffer.size) blf.enable(font_id, blf.NO_FALLBACK) return font_id def imbuf_rgb_new(size: tuple[int, int]) -> imbuf.types.ImBuf: """ Allocate a fresh RGB-only PNG imbuf with a BYTE pixel buffer. ``planes=24`` makes the saved PNG RGB-only (no alpha channel), which keeps the reference images as straight RGB and lets idiff produce visible diffs. """ ibuf = imbuf.new(size, planes=24) ibuf.file_type = "PNG" ibuf.compress = 100 return ibuf @contextlib.contextmanager def _blf_wrap_width(font_id: int, wrap_width: int) -> Iterator[None]: """ Enable BLF WORD_WRAP for the body of the context, restore on exit. A negative ``wrap_width`` is a sentinel meaning "leave WORD_WRAP untouched". The try/finally ensures WORD_WRAP is disabled even if the body raises. """ if wrap_width < 0: yield return blf.enable(font_id, blf.WORD_WRAP) blf.word_wrap(font_id, wrap_width) try: yield finally: blf.disable(font_id, blf.WORD_WRAP) def render_text_buffer(font_id: int, case: CaseBuffer) -> imbuf.types.ImBuf: """Render text into an imbuf via BLF buffer drawing.""" blf.size(font_id, case.font_size) ibuf = imbuf_rgb_new(case.size) # Opaque-black background. The internal buffer is RGBA; alpha must be 1.0 # so BLF's draw_buffer composites text onto opaque pixels (otherwise # blending against alpha=0 produces darker text). with ibuf.with_buffer(write=True) as buf: buf.cast('B')[:] = BUFFER_CLEAR_PIXEL * (case.size[0] * case.size[1]) blf.color(font_id, 1.0, 1.0, 1.0, 1.0) with _blf_wrap_width(font_id, case.wrap_width), blf.bind_imbuf(font_id, ibuf, display_name="sRGB"): blf.position( font_id, case.position_offset[0], (case.size[1] - case.font_size) + case.position_offset[1], 0, ) blf.draw_buffer(font_id, case.text) return ibuf def render_text_gpu(font_id: int, case: CaseBuffer) -> imbuf.types.ImBuf: """Render text via the GPU draw path into an imbuf.""" blf.size(font_id, case.font_size) blf.color(font_id, 1.0, 1.0, 1.0, 1.0) w, h = case.size offscreen = gpu.types.GPUOffScreen(w, h) with _blf_wrap_width(font_id, case.wrap_width), offscreen.bind(): fb = gpu.state.active_framebuffer_get() fb.clear(color=(0.0, 0.0, 0.0, 1.0)) gpu.state.blend_set("ALPHA") gpu.state.viewport_set(0, 0, w, h) gpu.matrix.load_matrix(mathutils.Matrix.Identity(4)) gpu.matrix.load_projection_matrix( mathutils.Matrix( ((2 / w, 0, 0, -1), (0, 2 / h, 0, -1), (0, 0, -1, 0), (0, 0, 0, 1)), ) ) blf.position( font_id, case.position_offset[0], (h - case.font_size) + case.position_offset[1], 0, ) blf.draw(font_id, case.text) gpu.state.blend_set("NONE") pixel_buf = offscreen.texture_color.read() offscreen.free() ibuf = imbuf_rgb_new(case.size) # The internal buffer is still RGBA; force alpha to opaque so imbuf.write # doesn't treat anti-aliased GPU pixels as partially-transparent and # composite/discard them on save. gpu_bytes = bytearray(pixel_buf) gpu_bytes[3::4] = b"\xff" * (w * h) with ibuf.with_buffer(write=True) as pixels: pixels.cast('B')[:] = gpu_bytes return ibuf # --------------------------------------------------------------------------- # VFont utilities PHONETIC_ALPHABET: dict[str, str] = { "a": "alpha", "b": "bravo", "c": "charlie", "d": "delta", "e": "echo", "f": "foxtrot", "g": "golf", "h": "hotel", "i": "india", "j": "juliet", "k": "kilo", "l": "lima", "m": "mike", "n": "november", "o": "oscar", "p": "papa", "q": "quebec", "r": "romeo", "s": "sierra", "t": "tango", "u": "uniform", "v": "victor", "w": "whiskey", "x": "x-ray", "y": "yankee", "z": "zulu", } PHONETIC_WORDS: list[str] = list(PHONETIC_ALPHABET.values()) def generate_phonetic_alphabet( *, letters: tuple[str, str] = ("a", "z"), titlecase: bool = True, ) -> list[str]: """Return NATO phonetic alphabet words for a range of letters.""" start = ord(letters[0].lower()) end = ord(letters[1].lower()) words = [PHONETIC_ALPHABET[chr(c)] for c in range(start, end + 1)] if titlecase: words = [w.title() for w in words] return words def generate_random_words(word_count: int, seed: int, *, titlecase: bool = True) -> list[str]: """ Return *word_count* pseudo-random NATO phonetic words from *seed*. """ # Uses a simple generator so the output is deterministic and does not depend on `random`. state = seed & 0xFFFFFFFF words: list[str] = [] for _ in range(word_count): state = (state * 1103515245 + 12345) & 0xFFFFFFFF words.append(PHONETIC_WORDS[state % len(PHONETIC_WORDS)]) if titlecase: words = [w.title() for w in words] return words def vfont_create_text_object_from_case( case: CaseVFont, fonts: VFontSet, collection: bpy.types.Collection, default_material: tuple[float, float, float] | None = None, ) -> tuple[bpy.types.Curve, bpy.types.Object, list[bpy.types.Material]]: """Create a FONT curve object from a test case. Caller must remove on cleanup.""" body = case.text.body if isinstance(case.text, TextFormatCompose) else case.text curve = bpy.data.curves.new(name="_test_text", type='FONT') curve.body = body curve.size = case.font_size curve.font = fonts.regular curve.font_bold = fonts.bold curve.font_italic = fonts.italic curve.font_bold_italic = fonts.bold_italic curve.fill_mode = 'BOTH' curve.resolution_u = case.resolution curve.overflow = case.overflow curve.align_x = case.align_x curve.align_y = case.align_y curve.space_word = case.space_word curve.space_line = case.space_line curve.space_character = case.space_character curve.offset = case.geometry_offset curve.extrude = case.geometry_extrude materials_rgb = case.materials if case.materials is not None else ( [default_material] if default_material is not None else [] ) created_materials: list[bpy.types.Material] = [] for i, (r, g, b) in enumerate(materials_rgb): mat = bpy.data.materials.new(name="_test_mat_{:d}".format(i)) nodes = mat.node_tree.nodes nodes.clear() emission = nodes.new('ShaderNodeEmission') emission.inputs["Color"].default_value = (r, g, b, 1.0) emission.inputs["Strength"].default_value = 1.0 out = nodes.new('ShaderNodeOutputMaterial') mat.node_tree.links.new(emission.outputs["Emission"], out.inputs["Surface"]) curve.materials.append(mat) created_materials.append(mat) obj = bpy.data.objects.new(name="_test_text_obj", object_data=curve) collection.objects.link(obj) if isinstance(case.text, TextFormatCompose): bpy.context.view_layer.objects.active = obj bpy.context.view_layer.update() for i, span in enumerate(case.text.spans): ci = curve.body_format[i] ci.use_bold = span.bold ci.use_italic = span.italic ci.use_underline = span.underline ci.use_small_caps = span.smallcaps ci.material_index = span.material ci.kerning = span.kerning if case.text_boxes is not None: bpy.context.view_layer.objects.active = obj for i, tb_data in enumerate(case.text_boxes): if i >= len(curve.text_boxes): bpy.ops.font.textbox_add() tb = curve.text_boxes[i] tb.x = tb_data.x tb.y = tb_data.y tb.width = tb_data.width tb.height = tb_data.height return curve, obj, created_materials def vfont_to_triangles( case: CaseVFont, fonts: VFontSet, ) -> tuple[ list[tuple[float, float]], list[tuple[int, int, int]], list[int], ]: """ Build a VFont text object from the given case, convert to mesh, and extract 2D triangles. Returns ``(vertices, indices, material_indices)`` where vertices are ``(x, y)`` pairs, indices are triangle index triples, and material_indices is a per-triangle material slot. """ curve, obj, created_materials = vfont_create_text_object_from_case( case, fonts, bpy.context.collection, ) cm = case.with_context_fn(obj) if case.with_context_fn is not None else contextlib.nullcontext() try: with cm: bpy.context.view_layer.update() depsgraph = bpy.context.evaluated_depsgraph_get() eval_obj = obj.evaluated_get(depsgraph) mesh = eval_obj.to_mesh() mesh.calc_loop_triangles() verts = [(v.co.x, v.co.y) for v in mesh.vertices] tris = [(lt.vertices[0], lt.vertices[1], lt.vertices[2]) for lt in mesh.loop_triangles] tri_materials = [lt.material_index for lt in mesh.loop_triangles] eval_obj.to_mesh_clear() finally: bpy.data.objects.remove(obj) bpy.data.curves.remove(curve) for mat in created_materials: bpy.data.materials.remove(mat) return verts, tris, tri_materials def vfont_dimensions_from_verts( verts: list[tuple[float, float]], ) -> tuple[float, float]: """Return the (width, height) from pre-computed triangle vertices.""" if not verts: return (0.0, 0.0) xs = [v[0] for v in verts] ys = [v[1] for v in verts] return (max(xs) - min(xs), max(ys) - min(ys)) def check_image_bounds(ibuf: imbuf.types.ImBuf, name: str) -> str | None: """ Check that the image is correctly framed. Returns an error message string, or ``None`` if the image is OK. """ w, h = ibuf.size with ibuf.with_buffer() as pixels: data = bytes(pixels) def is_black(x: int, y: int) -> bool: off = (y * w + x) * 4 return bool(data[off] == 0 and data[off + 1] == 0 and data[off + 2] == 0) for x in range(w): if not is_black(x, 0): return "{:s}: non-black pixel at min_y edge (x={:d}), image=({:d}x{:d})".format(name, x, w, h) if not is_black(x, h - 1): return "{:s}: non-black pixel at max_y edge (x={:d}), image=({:d}x{:d})".format(name, x, w, h) for y in range(h): if not is_black(0, y): return "{:s}: non-black pixel at min_x edge (y={:d}), image=({:d}x{:d})".format(name, y, w, h) if not is_black(w - 1, y): return "{:s}: non-black pixel at max_x edge (y={:d}), image=({:d}x{:d})".format(name, y, w, h) margin_limit = 13 margin_min_y = 0 for y in range(h): if all(is_black(x, y) for x in range(w)): margin_min_y += 1 else: break margin_max_y = 0 for y in range(h - 1, -1, -1): if all(is_black(x, y) for x in range(w)): margin_max_y += 1 else: break margin_min_x = 0 for x in range(w): if all(is_black(x, y) for y in range(h)): margin_min_x += 1 else: break margin_max_x = 0 for x in range(w - 1, -1, -1): if all(is_black(x, y) for y in range(h)): margin_max_x += 1 else: break for edge, margin in ( ("min_x", margin_min_x), ("max_x", margin_max_x), ("min_y", margin_min_y), ("max_y", margin_max_y), ): if margin >= margin_limit: return ( "{:s}: excessive margin at {:s}, " "{:d} empty pixels (limit {:d}), " "margins=(min_x={:d}, max_x={:d}, min_y={:d}, max_y={:d}), " "image=({:d}x{:d})" ).format( name, edge, margin, margin_limit, margin_min_x, margin_max_x, margin_min_y, margin_max_y, w, h, ) return None def render_text_vfont( verts: list[tuple[float, float]], tris: list[tuple[int, int, int]], tri_materials: list[int], image_size: tuple[int, int], position_offset: tuple[float, float], materials: list[tuple[float, float, float]] | None = None, ) -> imbuf.types.ImBuf: """ Render pre-computed VFont triangles into an imbuf via GPU. Renders at ``globals_vfont.scale_oversample`` times the output resolution then scales down for basic anti-aliasing without increasing curve tessellation. """ w, h = image_size s = globals_vfont.scale_oversample sw, sh = w * s, h * s ppu = VFONT_PPU * s ox, oy = position_offset[0] * s, position_offset[1] * s pixel_verts = [(v[0] * ppu + ox, v[1] * ppu + oy) for v in verts] offscreen = gpu.types.GPUOffScreen(sw, sh) with offscreen.bind(): fb = gpu.state.active_framebuffer_get() fb.clear(color=(0.0, 0.0, 0.0, 1.0)) gpu.state.blend_set("ALPHA") gpu.state.viewport_set(0, 0, sw, sh) gpu.matrix.load_matrix(mathutils.Matrix.Identity(4)) gpu.matrix.load_projection_matrix( mathutils.Matrix( ((2 / sw, 0, 0, -1), (0, 2 / sh, 0, -1), (0, 0, -1, 0), (0, 0, 0, 1)), ) ) if pixel_verts and tris: shader = gpu.shader.from_builtin('UNIFORM_COLOR') if materials is not None: groups: dict[int, list[tuple[int, int, int]]] = {} for tri, mat_idx in zip(tris, tri_materials): groups.setdefault(mat_idx, []).append(tri) for mat_idx, group_tris in groups.items(): r, g, b = materials[mat_idx] if mat_idx < len(materials) else (1.0, 1.0, 1.0) batch = batch_for_shader( shader, 'TRIS', {"pos": pixel_verts}, indices=group_tris, ) shader.uniform_float("color", (r, g, b, 1.0)) batch.draw(shader) else: batch = batch_for_shader(shader, 'TRIS', {"pos": pixel_verts}, indices=tris) shader.uniform_float("color", (1.0, 1.0, 1.0, 1.0)) batch.draw(shader) gpu.state.blend_set("NONE") pixel_buf = offscreen.texture_color.read() offscreen.free() ibuf_hi = imbuf_rgb_new((sw, sh)) gpu_bytes = bytearray(pixel_buf) gpu_bytes[3::4] = b"\xff" * (sw * sh) with ibuf_hi.with_buffer(write=True) as pixels: pixels.cast('B')[:] = gpu_bytes ibuf_hi.resize(image_size, method='BILINEAR') return ibuf_hi def render_text_vfont_camera( case: CaseVFont, fonts: VFontSet, image_size: tuple[int, int], position_offset: tuple[float, float], ) -> imbuf.types.ImBuf: """ Render a VFont text case using Cycles with an orthographic camera. Requires ``VFONT_RENDER_CTX`` to be initialized (done by ``main()`` before ``unittest.main()`` runs). Session-level setup (scene properties, camera, hidden objects, temp file) lives in the context; this function only creates and destroys the per-case text object. Camera is orthographic, positioned so that VFont world-space coordinates map to pixels identically to the GPU triangle path:: pixel_x = vertex_x * VFONT_PPU + position_offset[0] pixel_y = vertex_y * VFONT_PPU + position_offset[1] """ ctx = VFONT_RENDER_CTX assert ctx is not None, "VFONT_RENDER_CTX must be initialised before rendering" w, h = image_size s = globals_vfont.scale_oversample sw, sh = w * s, h * s scene = ctx._scene # Render at 2x then bilinear-downscale: matches the supersampling in render_text_vfont. # Camera framing is in VFont world-space, independent of render resolution. scene.render.resolution_x = sw scene.render.resolution_y = sh scene.render.resolution_percentage = 100 ctx.cam_data.ortho_scale = w / VFONT_PPU ctx.cam_obj.location = ( (w / 2.0 - position_offset[0]) / VFONT_PPU, (h / 2.0 - position_offset[1]) / VFONT_PPU, 10.0, ) curve, text_obj, created_materials = vfont_create_text_object_from_case( case, fonts, scene.collection, default_material=(1.0, 1.0, 1.0), ) cm = case.with_context_fn(text_obj) if case.with_context_fn is not None else contextlib.nullcontext() try: with cm: bpy.ops.render.render(write_still=True) ibuf = imbuf.load(ctx.tmp_path) ibuf.resize(image_size, method='BILINEAR') finally: bpy.data.objects.remove(text_obj) bpy.data.curves.remove(curve) for mat in created_materials: bpy.data.materials.remove(mat) return ibuf # --------------------------------------------------------------------------- # HTML report (only used with --show-html) def write_html_report(html_path: str) -> None: """ Write an HTML report showing all compared images with idiff results. With --mode=ALL the report contains one top-level section per kind (BUFFER / GPU / VFONT), each visually separated as if they were independent reports concatenated into a single document. """ import base64 from html import escape as html_escape def img_uri(path: str) -> str: if not os.path.exists(path): return "" with open(path, "rb") as fh_img: b64 = base64.b64encode(fh_img.read()).decode("ascii") return "data:image/png;base64,{:s}".format(b64) # Preserve insertion order: collect entries per kind in the order they were # produced, then walk kinds in that order so the report matches the test # execution order. by_kind: dict[str, list[ComparedImage]] = {} for entry in COMPARE_IMAGES: by_kind.setdefault(entry.kind, []).append(entry) count_failed_total = 0 count_total = len(COMPARE_IMAGES) with open(html_path, "w") as fh: fh.write( "\n" "BLF Test Report\n" "\n" "

BLF Test Report

\n" ) # Summary. fh.write("\n") for kind_idx, (kind, entries) in enumerate(by_kind.items()): if kind_idx > 0: fh.write("
\n") count_failed_kind = sum(1 for e in entries if not e.passed) count_kind = len(entries) fh.write( "

{kind:s}" " ({passed:d}/{total:d} passed)

\n".format( kind_lower=kind, kind=kind.upper(), passed=count_kind - count_failed_kind, total=count_kind, ) ) count_failed_total += count_failed_kind current_group = "" for entry in entries: if entry.group != current_group: if current_group: fh.write("\n
\n") fh.write("

{:s}

\n\n".format(entry.group)) current_group = entry.group if not entry.passed: status = "FAIL" else: status = "PASS" # Strip redundant lines from idiff output, keep only the stats. idiff_stats = "\n".join( line for line in entry.idiff_output.splitlines() if not (line in {"PASS", "FAILURE"} or line.startswith("Comparing ")) ) text_escaped = html_escape(entry.text).replace("\n", "
") diff_uri = img_uri(entry.diff_path) if entry.diff_path else "" diff_cell = ( " \n".format(diff_uri) if diff_uri else "" ) fh.write( "\n" "\n" " \n" " \n" "{diff:s}" "\n".format( name=html_escape(entry.name), status=status, text=text_escaped, output=html_escape(idiff_stats), ref=img_uri(entry.ref_path), test=img_uri(entry.test_path), diff=diff_cell, ) ) if entries: fh.write("
Difference (10x)

{name:s} {status:s}

" "
{text:s}
" "
{output:s}
Before (reference)
After (test output)
\n") fh.write( "
\n" "

{count_passed:d}/{count_total:d} passed (across all kinds)

\n" "\n".format( count_passed=count_total - count_failed_total, count_total=count_total, ) ) print("HTML report: {:s}".format(html_path)) # --------------------------------------------------------------------------- # Test MixIn class TestImageComparison_MixIn: """ Base class for tests that compare rendered images against references. Sub-classes must define ``name_prefix`` and at least one of ``cases_buffer`` or ``cases_vfont`` class attributes, and inherit from both this class and :class:`unittest.TestCase`. ``cases_buffer`` drives the BLF buffer/gpu render pipeline; ``cases_vfont`` drives the bpy VectorFont (3D Text) pipeline. The same class can define both lists - case names live in separate namespaces per kind, so ``test_buffer_xv`` and ``test_vfont_xv`` coexist. For each case in ``cases_buffer`` test methods are attached after main() parses --mode by attach_test_methods(): ``test_buffer_`` (which also runs the dimensions check) and ``test_gpu_``. ``cases_vfont`` produces ``test_vfont_``. They are selectable via the standard unittest CLI: ./blender.bin --background --factory-startup --python .py -- TestX.test_buffer_ascii_sweep ./blender.bin ... -- -k ascii_sweep """ # To be overridden. name_prefix: str = "" cases_buffer: list[CaseBuffer] = [] cases_vfont: list[CaseVFont] = [] def __init_subclass__(cls, **kwargs: object) -> None: super().__init_subclass__(**kwargs) has_buffer = "cases_buffer" in cls.__dict__ has_vfont = "cases_vfont" in cls.__dict__ if not (has_buffer or has_vfont): raise TypeError( "{:s} must define cases_buffer and/or cases_vfont".format(cls.__name__), ) if "name_prefix" not in cls.__dict__: raise TypeError("{:s} must define 'name_prefix'".format(cls.__name__)) # Catch typos like "combining_kerning" (no dot) - without the trailing # dot, generated filenames concatenate the case name without a separator. if not cls.name_prefix.endswith("."): raise TypeError( "{:s}.name_prefix {!r} must end with '.'".format(cls.__name__, cls.name_prefix), ) TEST_CLASSES.append(cls) def setUp(self) -> None: assert isinstance(self, unittest.TestCase) self.font_id = load_font_blf() self.fonts_vfont = FONTS_VFONT @staticmethod def _scale_case(case: CaseBuffer) -> CaseBuffer: """Apply globals_buffer.test_scale to a case's pixel-dependent fields.""" s = globals_buffer.test_scale return case._replace( size=(case.size[0] * s, case.size[1] * s), font_size=case.font_size * s, wrap_width=case.wrap_width * s if case.wrap_width >= 0 else -1, position_offset=(case.position_offset[0] * s, case.position_offset[1] * s), ) # Map of kind -> render function. Looked up by _run_render_case. _RENDER_FUNCS = { "buffer": render_text_buffer, "gpu": render_text_gpu, } def _run_render_case(self, case: CaseBuffer, kind: str) -> None: """ Render ``case`` via the kind's pipeline ('buffer' or 'gpu') and compare. For ``kind == "buffer"`` this also asserts ``blf.dimensions`` matches ``case.expected_dimensions``. The dimensions check is pipeline-independent so it doesn't run again for ``"gpu"``. """ assert isinstance(self, unittest.TestCase) if case.prepare or USE_PREPARE_FORCE: if kind == "buffer": self._print_prepare_buffer(case) # GPU shares buffer references, so prepare only prints for buffer. return if kind == "gpu" and gpu is None: self.skipTest("GPU not available") if kind == "buffer": self._check_dimensions(case) case = self._scale_case(case) render_fn = self._RENDER_FUNCS[kind] ibuf = render_fn(self.font_id, case) self._compare_image( case.name, case.text, ibuf, kind, idiff_fail=IDIFF_FAIL, idiff_fail_percent=IDIFF_FAIL_PERCENT, ) def _compute_prepare_buffer( self, case: CaseBuffer, ) -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]: """ Derive ``(size, position_offset, expected_dimensions)`` for a buffer case. Renders into an over-sized scratch buffer (margin = ``PREPARE_SEARCH_MARGIN_PX``) so content can extend in any direction without clipping, then crops to the final margin (``PREPARE_MARGIN_PX``) and translates the offset accordingly. BLF buffer rendering interprets ``position.y`` differently depending on the text (single line vs wrapped); the default offset is tried first, then a search runs only if content was clipped at an edge. """ assert isinstance(self, unittest.TestCase) blf.size(self.font_id, case.font_size) with _blf_wrap_width(self.font_id, case.wrap_width): w, h = blf.dimensions(self.font_id, case.text) dims = (math.ceil(w), math.ceil(h)) search_margin_both = 2 * PREPARE_SEARCH_MARGIN_PX search_w = dims[0] + search_margin_both search_h = dims[1] + search_margin_both row_size = search_w * BUFFER_BYTES_PER_PIXEL blank_row = BUFFER_CLEAR_PIXEL * search_w def render_extent(offset: int) -> tuple[int, int, int]: """Render at the given offset, return ``(extent, first_row, last_row)``.""" scratch_case = case._replace( size=(search_w, search_h), position_offset=(PREPARE_SEARCH_MARGIN_PX, offset), ) ibuf = render_text_buffer(self.font_id, scratch_case) with ibuf.with_buffer() as pixels: # Walk in from each edge; row equality is a single C-level compare. first_row = -1 for y in range(search_h): if pixels[y * row_size:(y + 1) * row_size] != blank_row: first_row = y break if first_row == -1: return 0, -1, -1 # `first_row` is non-blank, so stop one past it. for y in range(search_h - 1, first_row, -1): if pixels[y * row_size:(y + 1) * row_size] != blank_row: return (y - first_row) + 1, first_row, y # No non-blank row beyond `first_row` - content is exactly that one row. return 1, first_row, first_row # Thanks to the over-sized scratch buffer, the default offset usually works. # Only search if content was clipped at an edge. best_offset = 0 best_extent, best_first_row, best_last_row = render_extent(0) if best_extent < dims[1]: # Aim for ~50 search points across content height, but never finer than 5px. search_steps_target = 50 search_step_min_px = 5 search_step = max(search_step_min_px, dims[1] // search_steps_target) # Search up to one content height (plus search margin) in either direction. # Clipped at top -> positive offset; clipped at bottom -> negative offset. search_reach = dims[1] + PREPARE_SEARCH_MARGIN_PX clipped_top = best_first_row == 0 clipped_bottom = best_last_row == search_h - 1 if clipped_top and not clipped_bottom: search_range: range | itertools.chain[int] = range( search_step, search_reach + 1, search_step, ) elif clipped_bottom and not clipped_top: search_range = range(-search_step, -search_reach - 1, -search_step) else: # Ambiguous (or no content) - search both directions. search_range = itertools.chain( range(-search_step, -search_reach - 1, -search_step), range(search_step, search_reach + 1, search_step), ) for offset in search_range: extent, first_row, last_row = render_extent(offset) if extent > best_extent: best_extent = extent best_offset = offset best_first_row = first_row best_last_row = last_row if best_extent >= dims[1]: break # Crop to the final margin. The final buffer is just large enough to hold the # measured content extent, with `PREPARE_MARGIN_PX` padding on each side. # The offset translation preserves where content lands relative to the buffer: # shifting `position_y` by N shifts content rows by N (translation invariance). final_margin_both = 2 * PREPARE_MARGIN_PX final_size_w = dims[0] + final_margin_both final_size_h = best_extent + final_margin_both # search_position_y = (search_h - font_size) + best_offset # final_position_y = search_position_y + (PREPARE_MARGIN_PX - best_first_row) # final_offset = final_position_y - (final_size_h - font_size) final_offset = ( (search_h - final_size_h) + best_offset + (PREPARE_MARGIN_PX - best_first_row) ) return (final_size_w, final_size_h), (PREPARE_MARGIN_PX, final_offset), dims def _print_prepare_buffer(self, case: CaseBuffer) -> None: """Print proposed values for a buffer test case (used with ``prepare=True``).""" size, offset, dims = self._compute_prepare_buffer(case) print(" prepare {:s}.{:s}:".format(case.name, "buffer")) print(" expected_dimensions={!r},".format(dims)) print(" size=({:d}, {:d}),".format(size[0], size[1])) print(" position_offset=({:d}, {:d}),".format(offset[0], offset[1])) def _check_dimensions(self, case: CaseBuffer) -> None: """ Assert ``blf.dimensions(text)`` matches ``case.expected_dimensions``. Skipped during ``--generate`` so reference regeneration isn't blocked by stale expected_dimensions in the source. """ assert isinstance(self, unittest.TestCase) if USE_GENERATE_TEST_DATA: return blf.size(self.font_id, case.font_size) with _blf_wrap_width(self.font_id, case.wrap_width): w, h = blf.dimensions(self.font_id, case.text) self.assertEqual( (int(w), int(h)), case.expected_dimensions, "Dimensions mismatch for {:s}: got ({:.0f}, {:.0f}) expected {!r}".format( case.name, w, h, case.expected_dimensions, ), ) def assertAlmostEqualForEach( self, actual: tuple[float, ...], expected: tuple[float, ...], places: int = 4, msg: str | None = None, ) -> None: """Element-wise approximate equality for two same-length sequences.""" assert isinstance(self, unittest.TestCase) for i, (a, e) in enumerate(zip(actual, expected, strict=True)): if round(abs(a - e), places) != 0: self.fail( (msg + " " if msg else "") + "Mismatch at index {:d} ({:.{p}f} vs {:.{p}f}) in {!r} vs {!r}".format( i, a, e, actual, expected, p=places, ) ) def _run_case_vfont(self, case: CaseVFont) -> None: """VFont pipeline: bpy curve -> triangles -> render. Bundles dims + render.""" assert isinstance(self, unittest.TestCase) assert self.fonts_vfont is not None verts, tris, tri_materials = vfont_to_triangles(case, self.fonts_vfont) w, h = vfont_dimensions_from_verts(verts) if case.prepare or USE_PREPARE_FORCE: self._print_prepare_vfont(case, verts, w, h) return if not USE_GENERATE_TEST_DATA: self.assertAlmostEqualForEach( (w, h), case.expected_dimensions, places=4, msg="Dimensions mismatch for {:s}:".format(case.name), ) if USE_VFONT_RENDER: ibuf = render_text_vfont_camera(case, self.fonts_vfont, case.size, case.position_offset) else: ibuf = render_text_vfont(verts, tris, tri_materials, case.size, case.position_offset, case.materials) if USE_TEST_BOUNDS_ERROR: bounds_err = check_image_bounds(ibuf, case.name) self.assertIsNone(bounds_err, bounds_err) text_display = case.text.body if isinstance(case.text, TextFormatCompose) else case.text # Camera render uses a slightly looser threshold: Cycles rasterizes glyph # boundaries differently from the GPU uniform_color shader, leaving O(1-5) pixels # per image just above 0.01. Allow up to 0.1 % of pixels to exceed the threshold. idiff_fail = 0.01 if USE_VFONT_RENDER else 0.004 idiff_fail_percent = 0.11 if USE_VFONT_RENDER else 0.0 self._compare_image( case.name, text_display, ibuf, "vfont", idiff_fail=idiff_fail, idiff_fail_percent=idiff_fail_percent, ) @staticmethod def _compute_prepare_vfont( verts: list[tuple[float, float]], w: float, h: float, ) -> tuple[tuple[int, int], tuple[float, float], tuple[float, float]]: """Derive ``(size, position_offset, expected_dimensions)`` for a VFont case.""" if verts: min_x = min(v[0] for v in verts) min_y = min(v[1] for v in verts) offset_x = -min_x * VFONT_PPU + PREPARE_MARGIN_PX offset_y = -min_y * VFONT_PPU + PREPARE_MARGIN_PX size_w = math.ceil(w * VFONT_PPU) + (2 * PREPARE_MARGIN_PX) size_h = math.ceil(h * VFONT_PPU) + (2 * PREPARE_MARGIN_PX) else: offset_x = offset_y = float(PREPARE_MARGIN_PX) size_w = size_h = 2 * PREPARE_MARGIN_PX return (size_w, size_h), (offset_x, offset_y), (w, h) @staticmethod def _print_prepare_vfont( case: CaseVFont, verts: list[tuple[float, float]], w: float, h: float, ) -> None: """Print proposed values for a VFont test case (used with ``prepare=True``).""" size, offset, dims = TestImageComparison_MixIn._compute_prepare_vfont(verts, w, h) print(" prepare {:s}.{:s}:".format(case.name, "vfont")) print(" expected_dimensions=({:.6f}, {:.6f}),".format(dims[0], dims[1])) print(" size=({:d}, {:d}),".format(size[0], size[1])) print(" position_offset=({:.1f}, {:.1f}),".format(offset[0], offset[1])) # Maps rendering kind to the directory holding reference images. Buffer and # GPU share a single set of references (GPU must match buffer output). _RENDER_DIRS: dict[str, str] = { "buffer": RENDER_DIR_BUFFER, "gpu": RENDER_DIR_BUFFER, "vfont": RENDER_DIR_VFONT, } def _compare_image( self, name: str, text: str, ibuf: imbuf.types.ImBuf, kind: str, *, idiff_fail: float, idiff_fail_percent: float, ) -> None: """ Compare rendered image against a reference using idiff, or generate if --generate. ``kind`` is the rendering pipeline name ("buffer", "gpu", or "vfont"); it tags the ComparedImage entry for HTML grouping and disambiguates the on-disk ``_test.png`` filename so the two pipelines don't overwrite each other. The reference image has no kind suffix (one golden file per case). ``idiff_fail`` sets the per-channel failure threshold passed to idiff. ``idiff_fail_percent`` sets the allowable fraction of pixels (percent) that may exceed the threshold before the comparison fails. """ assert isinstance(self, unittest.TestCase) name_full = self.name_prefix + name render_dir = self._RENDER_DIRS[kind] ref_path = os.path.join(render_dir, "{:s}.png".format(name_full)) out_path = os.path.join(OUTPUT_DIR, "{:s}_{:s}_test.png".format(name_full, kind)) if USE_GENERATE_TEST_DATA and kind != "gpu": # Reference images are generated from the buffer/vfont paths only. # GPU must match the same buffer reference - any difference is a bug. imbuf.write(ibuf, filepath=ref_path) print(" Generated: {:s}".format(ref_path)) passed = True test_path = ref_path diff_path = "" idiff_output = "generated" else: self.assertTrue(os.path.exists(ref_path), "Reference image missing: {:s}".format(ref_path)) imbuf.write(ibuf, filepath=out_path) self.assertTrue(IDIFF_BIN, "idiff not found, set IDIFF_BIN or install OpenImageIO") diff_path = os.path.join(OUTPUT_DIR, "{:s}_{:s}_diff.png".format(name_full, kind)) result = subprocess.run( [IDIFF_BIN, "-fail", "{:.4f}".format(idiff_fail), "-failpercent", "{:.4f}".format(idiff_fail_percent), "-o", diff_path, "-abs", "-scale", "10", ref_path, out_path], capture_output=True, text=True, ) # 0 = identical, 1 = within warn/failpercent tolerance, 2 = fail, 3+ = error. passed = result.returncode in {0, 1} test_path = out_path idiff_output = result.stdout.rstrip() COMPARE_IMAGES.append(ComparedImage( kind=kind, group=type(self).__name__, name="{:s}_{:s}".format(name_full, kind), text=text, ref_path=ref_path, test_path=test_path, diff_path=diff_path, passed=passed, idiff_output=idiff_output, )) if not passed: self.fail( "Image {:s}_{:s} differs from reference:\n{:s}".format( name_full, kind, idiff_output, ) ) # --------------------------------------------------------------------------- # Generic Tests (both buffer & VFont) class TestCombiningKerning(TestImageComparison_MixIn, unittest.TestCase): """Test that kerning is correct after combining characters.""" name_prefix = "combining_kerning." _cases_generic = [ CaseGeneric( name="Yolanda", text=unicodedata.normalize("NFD", "\u0178olanda"), buffer=GenericParamsBuffer( size=(293, 84), expected_dimensions=(273, 65), ), vfont=GenericParamsVFont( size=(204, 65), expected_dimensions=(2.543269, 0.623712), position_offset=(10.0, 10.5), ), ), CaseGeneric( name="XV", text="X\u0308V", buffer=GenericParamsBuffer( size=(106, 83), expected_dimensions=(86, 64), ), vfont=GenericParamsVFont( size=(79, 65), expected_dimensions=(0.811813, 0.616844), position_offset=(9.8, 10.0), ), ), CaseGeneric( name="ro", text="r\u0308o", buffer=GenericParamsBuffer( size=(94, 73), expected_dimensions=(74, 53), ), vfont=GenericParamsVFont( size=(65, 57), expected_dimensions=(0.616758, 0.508328), position_offset=(6.7, 10.5), ), ), CaseGeneric( name="Avatar", text=unicodedata.normalize("NFD", "\u00C0vatar"), buffer=GenericParamsBuffer( size=(239, 89), expected_dimensions=(219, 69), ), vfont=GenericParamsVFont( size=(170, 69), expected_dimensions=(2.079670, 0.667668), position_offset=(10.0, 10.5), ), ), CaseGeneric( name="Eve", text=unicodedata.normalize("NFD", "\u00C8ve"), buffer=GenericParamsBuffer( size=(138, 89), expected_dimensions=(118, 69), ), vfont=GenericParamsVFont( size=(94, 68), expected_dimensions=(1.016484, 0.665608), position_offset=(5.2, 10.5), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestStrangeCharacters(TestImageComparison_MixIn, unittest.TestCase): """Test that control characters, zero-width characters, and edge cases don't crash.""" name_prefix = "strange_characters." _cases_generic = [ CaseGeneric( name="tab", text="A\tB", buffer=GenericParamsBuffer( size=(132, 72), expected_dimensions=(112, 52), ), vfont=GenericParamsVFont( size=(194, 56), expected_dimensions=(2.409341, 0.492445), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="carriage_return", text="A\rB", buffer=GenericParamsBuffer( size=(113, 72), expected_dimensions=(93, 52), ), vfont=GenericParamsVFont( size=(94, 56), expected_dimensions=(1.026786, 0.492445), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="line_feed", text="A\nB", buffer=GenericParamsBuffer( size=(67, 170), expected_dimensions=(47, 150), wrap_width=WRAP_WIDTH_LARGE, ), vfont=GenericParamsVFont( size=(52, 128), expected_dimensions=(0.438187, 1.492445), position_offset=(10.0, 82.0), ), ), CaseGeneric( name="cr_lf", text="A\r\nB", buffer=GenericParamsBuffer( size=(67, 170), expected_dimensions=(47, 150), wrap_width=WRAP_WIDTH_LARGE, ), vfont=GenericParamsVFont( size=(52, 128), expected_dimensions=(0.438187, 1.492445), position_offset=(10.0, 82.0), ), ), CaseGeneric( name="bom", text="A\ufeffB", buffer=GenericParamsBuffer( size=(113, 72), expected_dimensions=(93, 57), ), vfont=GenericParamsVFont( size=(82, 56), expected_dimensions=(0.848214, 0.492445), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="zero_width_joiner", text="A\u200dB", buffer=GenericParamsBuffer( size=(113, 72), expected_dimensions=(93, 57), ), vfont=GenericParamsVFont( size=(82, 56), expected_dimensions=(0.848214, 0.492445), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="replacement_char", text="A\ufffdB", buffer=GenericParamsBuffer( size=(185, 87), expected_dimensions=(165, 67), ), vfont=GenericParamsVFont( size=(131, 66), expected_dimensions=(1.535028, 0.629121), position_offset=(10.0, 17.7), ), ), CaseGeneric( name="empty_string", text="", buffer=GenericParamsBuffer( size=(20, 20), expected_dimensions=(0, 0), ), vfont=GenericParamsVFont( size=(20, 20), expected_dimensions=(0.000000, 0.000000), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="only_combining", text="\u0308\u0301", buffer=GenericParamsBuffer( size=(42, 33), expected_dimensions=(22, 13), ), vfont=GenericParamsVFont( size=(34, 28), expected_dimensions=(0.192995, 0.109890), position_offset=(16.9, -20.0), ), ), CaseGeneric( name="double_combining", text="U\u0308\u0301B", buffer=GenericParamsBuffer( size=(120, 100), expected_dimensions=(100, 81), ), vfont=GenericParamsVFont( size=(82, 78), expected_dimensions=(0.849588, 0.792067), position_offset=(5.5, 10.5), ), ), CaseGeneric( name="ascii_sweep", text="".join(( *(chr(c) for c in range(1, 256)), "\n\n", *(chr(c) for c in range(255, 0, -1)), )), buffer=GenericParamsBuffer( size=(1941, 76), expected_dimensions=(1921, 76), position_offset=(10, 9), font_size=globals_buffer.size // 5, wrap_width=WRAP_WIDTH_LARGE, ), vfont=GenericParamsVFont( size=(2221, 88), expected_dimensions=(30.562490, 0.937958), position_offset=(10.0, 86.0), font_size=globals_vfont.size / 3.0, ), ), CaseGeneric( name="tiny_buffer", text="A\u0308B", buffer=GenericParamsBuffer( size=(113, 83), expected_dimensions=(93, 64), ), vfont=GenericParamsVFont( size=(82, 65), expected_dimensions=(0.848214, 0.618905), position_offset=(10.0, 10.0), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestCombiningWordWrap(TestImageComparison_MixIn, unittest.TestCase): """ Test that word-wrap works correctly with combining characters. """ name_prefix = "combining_word_wrap." cases_buffer = [ CaseBuffer( name="combining", text=unicodedata.normalize("NFD", "\u00C8ve \u00C0vatar \u0178olanda r\u00E9sum\u00E9 na\u00EFve"), size=(293, 481), expected_dimensions=(273, 461), position_offset=(10, -6), wrap_width=230, ), # Combining mark right at the wrap boundary. CaseBuffer( name="boundary", text=unicodedata.normalize("NFD", "\u0178olanda \u0178olanda \u0178olanda"), size=(293, 280), expected_dimensions=(273, 261), wrap_width=280, position_offset=(10, -1), ), # Single long word that can't wrap, with many combining marks. CaseBuffer( name="no_break", text=unicodedata.normalize("NFD", "\u00C0v\u00E0t\u00E0r\u00E0v\u00E0t\u00E0r"), size=(452, 89), expected_dimensions=(432, 69), wrap_width=180, position_offset=(10, -6), ), # Lorem ipsum paragraph with combining characters at small font size. CaseBuffer( name="lorem", text=generate_lorem_combining(280, seed=42), size=(408, 481), expected_dimensions=(388, 461), font_size=12, wrap_width=400, position_offset=(10, -10), ), # Stress test: Latin block + Roman Numerals, grouped into 8-char words. CaseBuffer( name="stress", text=generate_stress_text(word_size=8), size=(404, 190), expected_dimensions=(384, 176), font_size=12, wrap_width=400, position_offset=(10, 8), ), # Tiny font size with word wrap, combining lorem ipsum in a 100x100 image. CaseBuffer( name="font_size_1", text=generate_lorem_combining(640, seed=7), size=(81, 61), expected_dimensions=(61, 42), font_size=1, wrap_width=62, position_offset=(10, -12), ), # Wrap width of 1 forces a break after every space. CaseBuffer( name="width_1", text=unicodedata.normalize("NFD", "\u00C0v \u00C8x"), size=(103, 186), expected_dimensions=(83, 166), wrap_width=1, position_offset=(10, -6), ), ] class TestMultiline(TestImageComparison_MixIn, unittest.TestCase): """Test multi-line text with literal newlines and combining characters.""" name_prefix = "multiline." cases_buffer = [ CaseBuffer( name="combining", text=unicodedata.normalize("NFD", "\u00C8ve\n\u0178olanda"), size=(293, 187), expected_dimensions=(273, 167), wrap_width=9999, position_offset=(10, -6), ), CaseBuffer( name="plain", text="Hello\nWorld", size=(224, 174), expected_dimensions=(204, 154), wrap_width=9999, position_offset=(10, 7), ), # Combining mark on the last character before a newline. CaseBuffer( name="combining_before_newline", text="X\u0308\nY\u0308", size=(62, 181), expected_dimensions=(42, 162), wrap_width=9999, position_offset=(10, -1), ), # Combining mark on the first character after a newline. CaseBuffer( name="combining_after_newline", text="A\nU\u0308B", size=(120, 171), expected_dimensions=(100, 151), wrap_width=9999, position_offset=(10, 10), ), # Newline-only strings with wrap enabled should have zero dimensions. CaseBuffer( name="newline_only", text="\n", size=(20, 20), expected_dimensions=(0, 0), wrap_width=9999, position_offset=(10, 391), ), ] class TestCombiningPosition(TestImageComparison_MixIn, unittest.TestCase): """ Test that combining marks are visually positioned over their base character. Renders text into an image and compares against reference PNGs using idiff. Failed comparisons leave ``*_test.png`` files in the render directory. """ name_prefix = "combining_position." _cases_generic = [ CaseGeneric( name="diaeresis_Y", text="Y\u0308", buffer=GenericParamsBuffer( size=(61, 83), expected_dimensions=(41, 64), ), vfont=GenericParamsVFont( size=(48, 65), expected_dimensions=(0.388736, 0.616844), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="grave_A", text="A\u0300", buffer=GenericParamsBuffer( size=(66, 88), expected_dimensions=(46, 68), ), vfont=GenericParamsVFont( size=(52, 68), expected_dimensions=(0.438187, 0.660800), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="acute_e", text="e\u0301", buffer=GenericParamsBuffer( size=(61, 77), expected_dimensions=(41, 57), ), vfont=GenericParamsVFont( size=(43, 60), expected_dimensions=(0.314560, 0.550223), position_offset=(7.3, 10.5), ), ), CaseGeneric( name="phrase_combining", text=unicodedata.normalize("NFD", "\u00C0vatar \u00C8ve \u0178olanda"), buffer=GenericParamsBuffer( size=(668, 89), expected_dimensions=(648, 69), ), vfont=GenericParamsVFont( size=(460, 69), expected_dimensions=(6.108516, 0.667668), position_offset=(10.0, 10.5), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestStressCombining(TestImageComparison_MixIn, unittest.TestCase): """Stress tests for combining mark handling: stacking, enclosing, half marks.""" name_prefix = "stress_combining." _cases_generic = [ CaseGeneric( name="all_diacriticals", text="X" + "".join(chr(c) for c in range(0x0300, 0x0370)), buffer=GenericParamsBuffer( size=(102, 1669), expected_dimensions=(82, 1649), position_offset=(10, -697), ), vfont=GenericParamsVFont( size=(76, 1246), expected_dimensions=(0.771978, 17.023011), position_offset=(9.8, 674.6), ), ), CaseGeneric( name="zalgo_100", text=generate_combining_stress("H", "\u0308\u0301\u0300\u0302\u0303", 20) + "ello", buffer=GenericParamsBuffer( size=(196, 1543), expected_dimensions=(176, 1524), ), vfont=GenericParamsVFont( size=(133, 1177), expected_dimensions=(1.562500, 16.069021), position_offset=(5.2, 10.5), ), ), CaseGeneric( name="cgj", text="A\u0308\u034f\u0301B", buffer=GenericParamsBuffer( size=(116, 156), expected_dimensions=(96, 136), ), vfont=GenericParamsVFont( size=(83, 117), expected_dimensions=(0.873970, 1.336109), position_offset=(11.9, 49.5), ), ), CaseGeneric( name="half_marks", text="A\ufe20B\ufe21C", buffer=GenericParamsBuffer( size=(159, 90), expected_dimensions=(139, 71), ), vfont=GenericParamsVFont( size=(114, 70), expected_dimensions=(1.298077, 0.686899), position_offset=(10.0, 10.5), ), ), CaseGeneric( name="enclosing", text="A\u20dd B\u20de C\u20e3", buffer=GenericParamsBuffer( size=(368, 85), expected_dimensions=(348, 78), ), vfont=GenericParamsVFont( size=(239, 64), expected_dimensions=(3.029671, 0.598971), position_offset=(10.0, 10.5), ), ), CaseGeneric( name="alphabet_above_and_below_combiners", text=generate_alphabet_above_and_below_combiners(line_count=8), buffer=GenericParamsBuffer( size=(658, 519), expected_dimensions=(638, 498), font_size=21, wrap_width=WRAP_WIDTH_LARGE, ), vfont=GenericParamsVFont( size=(544, 452), expected_dimensions=(7.268991, 5.991646), position_offset=(10.0, 422.5), font_size=globals_vfont.size * 0.35, ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestStressComplexScripts(TestImageComparison_MixIn, unittest.TestCase): """Test complex scripts with combining marks in context.""" name_prefix = "stress_complex_scripts." _cases_generic = [ # RTL script with combining vowel marks. # NOTE: The default font has no Arabic glyphs; rendered as placeholders. CaseGeneric( name="arabic", text="\u0628\u064e\u0633\u0650\u0645\u064f \u0627\u0644\u0644\u0651\u0647\u0650", buffer=GenericParamsBuffer( size=(723, 85), expected_dimensions=(703, 77), ), vfont=GenericParamsVFont( size=(432, 63), expected_dimensions=(5.717823, 0.592103), position_offset=(8.3, 10.0), ), ), # Indic script with combining vowel signs and conjunct-forming virama. # NOTE: The default font has no Devanagari glyphs; rendered as placeholders. CaseGeneric( name="devanagari", text="\u0928\u092e\u0938\u094d\u0924\u0947", buffer=GenericParamsBuffer( size=(362, 85), expected_dimensions=(342, 77), ), vfont=GenericParamsVFont( size=(217, 63), expected_dimensions=(2.732933, 0.592103), position_offset=(8.3, 10.0), ), ), # Combining above/below marks with no inter-word spaces. # NOTE: The default font has no Thai glyphs; rendered as placeholders. CaseGeneric( name="thai", text="\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e35\u0e04\u0e23\u0e31\u0e1a", buffer=GenericParamsBuffer( size=(590, 85), expected_dimensions=(570, 77), ), vfont=GenericParamsVFont( size=(352, 63), expected_dimensions=(4.603812, 0.592103), position_offset=(8.3, 10.0), ), ), # Composing syllables from separate jamo components. # NOTE: The default font has no Hangul glyphs; rendered as placeholders. CaseGeneric( name="hangul_jamo", text="\u1100\u1161\u11a8 \u1102\u1161\u11bc", buffer=GenericParamsBuffer( size=(381, 85), expected_dimensions=(361, 77), ), vfont=GenericParamsVFont( size=(230, 63), expected_dimensions=(2.911504, 0.592103), position_offset=(8.3, 10.0), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestStressWidths(TestImageComparison_MixIn, unittest.TestCase): """Stress tests for wide and variable-width characters.""" name_prefix = "stress_widths." _cases_generic = [ # NOTE: The default font has no CJK glyphs; rendered as placeholders. CaseGeneric( name="cjk", text="\u4e16\u754c\u4f60\u597d", buffer=GenericParamsBuffer( size=(248, 85), expected_dimensions=(228, 77), ), vfont=GenericParamsVFont( size=(150, 63), expected_dimensions=(1.797493, 0.592103), position_offset=(8.3, 10.0), ), ), CaseGeneric( name="fullwidth_latin", text="\uff21\uff22\uff23", buffer=GenericParamsBuffer( size=(191, 85), expected_dimensions=(171, 77), ), vfont=GenericParamsVFont( size=(116, 63), expected_dimensions=(1.329773, 0.592103), position_offset=(8.3, 10.0), ), ), CaseGeneric( name="halfwidth_kana", text="\uff76\uff77\uff78", buffer=GenericParamsBuffer( size=(191, 85), expected_dimensions=(171, 77), ), vfont=GenericParamsVFont( size=(116, 63), expected_dimensions=(1.329773, 0.592103), position_offset=(8.3, 10.0), ), ), CaseGeneric( name="fullwidth_space", text="\u4e16\u3000\u754c", buffer=GenericParamsBuffer( size=(191, 85), expected_dimensions=(171, 77), ), vfont=GenericParamsVFont( size=(116, 63), expected_dimensions=(1.329773, 0.592103), position_offset=(8.3, 10.0), ), ), CaseGeneric( name="mixed", text="\u4e16\u0308Hello", buffer=GenericParamsBuffer( size=(253, 110), expected_dimensions=(233, 90), position_offset=(10, -27), ), vfont=GenericParamsVFont( size=(170, 64), expected_dimensions=(2.073455, 0.598971), position_offset=(8.3, 10.5), ), ), CaseGeneric( name="unicode_spaces", text="A\u2007B\u2009C\u200aD", buffer=GenericParamsBuffer( size=(272, 74), expected_dimensions=(252, 54), ), vfont=GenericParamsVFont( size=(190, 57), expected_dimensions=(2.354396, 0.504121), position_offset=(10.0, 10.5), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestStressZeroWidth(TestImageComparison_MixIn, unittest.TestCase): """Stress tests for zero-width and invisible characters.""" name_prefix = "stress_zero_width." _cases_generic = [ CaseGeneric( name="var_selector", text="A\ufe00B\ufe01C", buffer=GenericParamsBuffer( size=(216, 85), expected_dimensions=(196, 82), ), vfont=GenericParamsVFont( size=(148, 64), expected_dimensions=(1.765797, 0.598971), position_offset=(10.0, 10.5), ), ), CaseGeneric( name="bom_start", text="\ufeffHello", buffer=GenericParamsBuffer( size=(196, 76), expected_dimensions=(176, 56), ), vfont=GenericParamsVFont( size=(133, 59), expected_dimensions=(1.562500, 0.528846), position_offset=(5.2, 10.5), ), ), CaseGeneric( name="word_joiner", text="Hello\u2060World", buffer=GenericParamsBuffer( size=(443, 76), expected_dimensions=(423, 56), ), vfont=GenericParamsVFont( size=(301, 59), expected_dimensions=(3.890797, 0.528846), position_offset=(5.2, 10.5), ), ), CaseGeneric( name="soft_hyphen", text="break\u00adable", buffer=GenericParamsBuffer( size=(380, 76), expected_dimensions=(360, 56), ), vfont=GenericParamsVFont( size=(261, 59), expected_dimensions=(3.345467, 0.528846), position_offset=(5.8, 10.5), ), ), CaseGeneric( name="sequence", text="A\u200b\u200c\u200d\u200eB", buffer=GenericParamsBuffer( size=(113, 150), expected_dimensions=(93, 130), ), vfont=GenericParamsVFont( size=(82, 113), expected_dimensions=(0.848214, 1.291552), position_offset=(10.0, 67.5), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestStressBidi(TestImageComparison_MixIn, unittest.TestCase): """Stress tests for bidirectional and layout control characters.""" name_prefix = "stress_bidi." _cases_generic = [ CaseGeneric( name="lrm_rlm", text="A\u200eB\u200fC", buffer=GenericParamsBuffer( size=(159, 138), expected_dimensions=(139, 117), ), vfont=GenericParamsVFont( size=(114, 101), expected_dimensions=(1.298077, 1.120965), position_offset=(10.0, 54.9), ), ), CaseGeneric( name="rtl_override", text="\u202eHello\u202c", buffer=GenericParamsBuffer( size=(205, 133), expected_dimensions=(185, 113), ), vfont=GenericParamsVFont( size=(144, 99), expected_dimensions=(1.708791, 1.089372), position_offset=(15.7, 16.6), ), ), CaseGeneric( name="nested_embed", text="\u202aHello \u202bWorld\u202c\u202c", buffer=GenericParamsBuffer( size=(428, 207), expected_dimensions=(408, 187), ), vfont=GenericParamsVFont( size=(294, 151), expected_dimensions=(3.803572, 1.816192), position_offset=(15.7, 100.2), ), ), CaseGeneric( name="mixed_script", text="Hello \u200f\u0645\u0631\u062d\u0628\u0627\u200e World", buffer=GenericParamsBuffer( size=(723, 149), expected_dimensions=(703, 141), ), vfont=GenericParamsVFont( size=(465, 66), expected_dimensions=(6.174450, 0.630580), position_offset=(5.2, 10.5), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestStressInvalidCodepoints(TestImageComparison_MixIn, unittest.TestCase): """Stress tests for invalid and undefined codepoints.""" name_prefix = "stress_invalid_codepoints." _cases_generic = [ CaseGeneric( name="nonchar_fdd0", text="A\ufdd0B", buffer=GenericParamsBuffer( size=(170, 85), expected_dimensions=(150, 77), ), vfont=GenericParamsVFont( size=(115, 63), expected_dimensions=(1.315934, 0.592103), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="nonchar_ffff", text="A\uffffB", buffer=GenericParamsBuffer( size=(170, 85), expected_dimensions=(150, 77), ), vfont=GenericParamsVFont( size=(115, 63), expected_dimensions=(1.315934, 0.592103), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="specials", text="\ufff0\ufff1\ufffd", buffer=GenericParamsBuffer( size=(206, 96), expected_dimensions=(186, 89), ), vfont=GenericParamsVFont( size=(134, 71), expected_dimensions=(1.570021, 0.699246), position_offset=(8.3, 17.7), ), ), CaseGeneric( name="replacement", text="\ufffd\ufffd\ufffd", buffer=GenericParamsBuffer( size=(236, 87), expected_dimensions=(216, 67), ), vfont=GenericParamsVFont( size=(165, 66), expected_dimensions=(2.003434, 0.629121), position_offset=(8.0, 17.7), ), ), CaseGeneric( name="max_codepoint", text="A\U0010ffffB", buffer=GenericParamsBuffer( size=(170, 85), expected_dimensions=(150, 77), ), vfont=GenericParamsVFont( size=(115, 63), expected_dimensions=(1.315934, 0.592103), position_offset=(10.0, 10.0), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) def test_surrogates_raise(self) -> None: """Lone surrogates should raise UnicodeEncodeError, not crash.""" for text in ["\ud800", "\udfff", "\ud800\udc00"]: with self.subTest(text=repr(text)): with self.assertRaises(UnicodeEncodeError): blf.dimensions(self.font_id, text) def test_embedded_null_raises(self) -> None: """Embedded null should raise ValueError, not silently truncate.""" with self.assertRaises(ValueError): blf.dimensions(self.font_id, "A\x00B") class TestStressSupplementary(TestImageComparison_MixIn, unittest.TestCase): """Stress tests for 4-byte UTF-8 (codepoints above U+FFFF).""" name_prefix = "stress_supplementary." cases_buffer = [ # Emoji (supplementary plane). CaseBuffer( name="emoji", text="A\U0001f600B\U0001f64fC", size=(273, 85), expected_dimensions=(253, 78), position_offset=(10, -2), ), # Mathematical Alphanumeric Symbols. CaseBuffer( name="math_alpha", text="\U0001d400\U0001d401\U0001d402", size=(191, 85), expected_dimensions=(171, 77), position_offset=(10, -2), ), # Emoji Zero Width Joiner (ZWJ) sequence. CaseBuffer( name="emoji_zwj", text="\U0001f468\u200d\U0001f469\u200d\U0001f467", size=(191, 85), expected_dimensions=(171, 82), position_offset=(10, -2), ), # Mixed BMP and supplementary. CaseBuffer( name="mixed", text="Hello \U0001f600 World \U0001d400", size=(571, 85), expected_dimensions=(551, 78), position_offset=(10, -2), ), ] class TestCombiningEdgePositions(TestImageComparison_MixIn, unittest.TestCase): """Test combining marks at edge positions: start, end, after space/zero-width.""" name_prefix = "combining_edge_positions." _cases_generic = [ CaseGeneric( name="combining_first", text="\u0308Hello", buffer=GenericParamsBuffer( size=(207, 76), expected_dimensions=(187, 56), ), vfont=GenericParamsVFont( size=(145, 59), expected_dimensions=(1.725618, 0.528846), position_offset=(16.9, 10.5), ), ), CaseGeneric( name="combining_after_space", text="A \u0308B", buffer=GenericParamsBuffer( size=(132, 72), expected_dimensions=(112, 52), ), vfont=GenericParamsVFont( size=(94, 56), expected_dimensions=(1.026786, 0.492445), position_offset=(10.0, 10.0), ), ), CaseGeneric( name="combining_at_end", text="Hello\u0308", buffer=GenericParamsBuffer( size=(196, 76), expected_dimensions=(176, 56), ), vfont=GenericParamsVFont( size=(133, 59), expected_dimensions=(1.562500, 0.528846), position_offset=(5.2, 10.5), ), ), CaseGeneric( name="many_combining_no_base", text="\u0308\u0301\u0300\u0302\u0303\u0304\u0305\u0306\u0307\u0309", buffer=GenericParamsBuffer( size=(51, 39), expected_dimensions=(31, 19), ), vfont=GenericParamsVFont( size=(41, 33), expected_dimensions=(0.286401, 0.173077), position_offset=(20.3, -19.2), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestCombiningSequences(TestImageComparison_MixIn, unittest.TestCase): """Test combining mark sequences: bursts, alternating, repeated.""" name_prefix = "combining_sequences." _cases_generic = [ CaseGeneric( name="base_then_burst", text="ABCDE\u0308\u0301\u0300", buffer=GenericParamsBuffer( size=(252, 116), expected_dimensions=(232, 97), ), vfont=GenericParamsVFont( size=(176, 90), expected_dimensions=(2.161401, 0.960422), position_offset=(10.0, 10.5), ), ), CaseGeneric( name="alternating", text="a\u0308b\u0308c\u0308d\u0308", buffer=GenericParamsBuffer( size=(183, 87), expected_dimensions=(163, 68), ), vfont=GenericParamsVFont( size=(126, 68), expected_dimensions=(1.469780, 0.655306), position_offset=(7.7, 10.5), ), ), CaseGeneric( name="repeated_mark", text="A\u0308\u0308\u0308", buffer=GenericParamsBuffer( size=(66, 106), expected_dimensions=(46, 87), ), vfont=GenericParamsVFont( size=(52, 83), expected_dimensions=(0.438187, 0.871824), position_offset=(10.0, 10.0), ), ), ] cases_buffer = case_generic_to_buffer(_cases_generic) cases_vfont = case_generic_to_vfont(_cases_generic) class TestWrapCombiningEdge(TestImageComparison_MixIn, unittest.TestCase): """Test word wrap when combining marks fall near the wrap boundary.""" name_prefix = "wrap_combining_edge." cases_buffer = [ # Combining mark on the last char before wrap boundary. CaseBuffer( name="at_combining", text="WWWWW\u0308 Next", size=(355, 182), expected_dimensions=(335, 163), wrap_width=300, position_offset=(10, -1), ), # Tighter wrap forcing break right after base+combining. CaseBuffer( name="combining_tight", text="WWWW\u0308 Next", size=(288, 182), expected_dimensions=(268, 163), wrap_width=200, position_offset=(10, -1), ), ] class TestStressEdgeParams(TestImageComparison_MixIn, unittest.TestCase): """Stress tests for edge-case parameter values.""" name_prefix = "stress_edge_params." cases_buffer = [ # Font size 1. CaseBuffer( name="font_size_1", text="Hello", size=(23, 21), expected_dimensions=(3, 4), font_size=1, position_offset=(10, -10), ), # Font size 2. CaseBuffer( name="font_size_2", text="Hello", size=(26, 22), expected_dimensions=(6, 3), font_size=2, position_offset=(10, -10), ), # wrap_width=0 (wraps every character). CaseBuffer( name="wrap_0", text="Hello World", size=(224, 174), expected_dimensions=(204, 154), wrap_width=0, position_offset=(10, 7), ), # Negative position (text off-screen, should not crash). CaseBuffer( name="negative_pos", text="Hello", size=(196, 76), expected_dimensions=(176, 56), position_offset=(10, 7), ), ] # --------------------------------------------------------------------------- # VFont Tests class TestBasic(TestImageComparison_MixIn, unittest.TestCase): """Basic VFont text rendering.""" name_prefix = "basic." cases_vfont = [ CaseVFont( name="alpha_bravo", text=" ".join(generate_phonetic_alphabet(letters=("a", "b"))), size=(299, 70), expected_dimensions=(3.869505, 0.686813), position_offset=(10.0, 21.9), ), CaseVFont( name="charlie_delta", text=" ".join(generate_phonetic_alphabet(letters=("c", "e"))), size=(438, 59), expected_dimensions=(5.798077, 0.528846), position_offset=(7.0, 10.5), ), ] class TestTextOnCurve(TestImageComparison_MixIn, unittest.TestCase): """Test rendering of vfont text laid out along a curve via ``follow_curve``.""" name_prefix = "text_on_curve." @staticmethod @contextlib.contextmanager def _circle(obj: bpy.types.Object) -> Iterator[None]: """ Create a Bezier circle and assign it as the text object's ``follow_curve``. Setup runs before depsgraph evaluation; ``finally`` removes the path object and curve so cleanup happens even if evaluation raises. """ path_curve = bpy.data.curves.new(name="_test_path", type='CURVE') path_curve.dimensions = '3D' spline = path_curve.splines.new('BEZIER') spline.bezier_points.add(3) # 4 points total. r = 2.0 h = r * 0.5523 # Standard 4-point Bezier circle handle ratio. points = ( ((r, 0.0, 0.0), (r, -h, 0.0), (r, h, 0.0)), ((0.0, r, 0.0), (h, r, 0.0), (-h, r, 0.0)), ((-r, 0.0, 0.0), (-r, h, 0.0), (-r, -h, 0.0)), ((0.0, -r, 0.0), (-h, -r, 0.0), (h, -r, 0.0)), ) for i, (co, hl, hr) in enumerate(points): bp = spline.bezier_points[i] bp.co = co bp.handle_left = hl bp.handle_right = hr spline.use_cyclic_u = True path_obj = bpy.data.objects.new(name="_test_path_obj", object_data=path_curve) bpy.context.collection.objects.link(path_obj) obj.data.follow_curve = path_obj # Shift the text body down 1.0 in its local frame so it sits below the # path baseline; on a tightly-curved circle this gives characters more # arc length per unit, eliminating overlap. obj.data.offset_y = -1.0 try: yield finally: bpy.data.objects.remove(path_obj) bpy.data.curves.remove(path_curve) cases_vfont = [ CaseVFont( name="circle", text=" ".join(generate_phonetic_alphabet(letters=("a", "m"))), font_size=0.6, size=(457, 455), expected_dimensions=(6.060367, 6.036401), position_offset=(230.5, 228.8), with_context_fn=_circle, ), ] class TestMaterials(TestImageComparison_MixIn, unittest.TestCase): """Test per-character material assignment with colored rendering.""" name_prefix = "materials." cases_vfont = [ # Two materials: red and blue. CaseVFont( name="two_colors", text=( TextFormatCompose() .style(material=0).text("Red") .text(" ") .style(material=1).text("Blue") ), size=(218, 59), expected_dimensions=(2.749313, 0.528846), position_offset=(5.2, 10.5), materials=[(1.0, 0.2, 0.2), (0.2, 0.4, 1.0)], ), # Three materials: red, green, blue. CaseVFont( name="three_colors", text=( TextFormatCompose() .style(material=0).text("Red ") .style(material=1).text("Green ") .style(material=2).text("Blue") ), size=(374, 59), expected_dimensions=(4.910715, 0.528846), position_offset=(5.2, 10.5), materials=[(1.0, 0.2, 0.2), (0.2, 1.0, 0.2), (0.2, 0.4, 1.0)], ), # Material with bold styling. CaseVFont( name="colored_bold", text=( TextFormatCompose() .style(material=0, bold=True).text("Yellow") .style(material=1, bold=False).text(" Cyan") ), size=(307, 70), expected_dimensions=(3.980408, 0.686097), position_offset=(10.0, 21.9), materials=[(1.0, 0.8, 0.2), (0.2, 0.8, 1.0)], ), # CMYK: each letter underlined and colored with its corresponding material slot. # K (Key) is a dark gray so it stays visible against the black background. CaseVFont( name="cmyk", text=( TextFormatCompose() .style(material=0, underline=True).text("C") .style(material=1, underline=True).text("M") .style(material=2, underline=True).text("Y") .style(material=3, underline=True).text("K") ), size=(155, 64), expected_dimensions=(1.870879, 0.597253), position_offset=(10.0, 17.2), materials=[ (0.0, 1.0, 1.0), # C - Cyan (1.0, 0.0, 1.0), # M - Magenta (1.0, 1.0, 0.0), # Y - Yellow (0.4, 0.4, 0.4), # K - Key (dark gray) ], ), ] class TestTextBoxes(TestImageComparison_MixIn, unittest.TestCase): """Test text layout with text boxes of various configurations.""" name_prefix = "text_boxes." cases_vfont = [ # Single narrow text box, forces word wrap. CaseVFont( name="narrow_wrap", text=" ".join(generate_random_words(3, seed=10)), size=(263, 214), expected_dimensions=(3.364011, 2.686813), position_offset=(7.5, 165.9), text_boxes=[TextBox(0.0, 0.0, 1.5, 0.0)], ), # Tall box with limited height, text should be clipped. CaseVFont( name="height_limit", text="\n".join(generate_random_words(4, seed=20)), size=(191, 275), expected_dimensions=(2.368818, 3.528846), position_offset=(13.9, 226.5), text_boxes=[TextBox(0.0, 0.0, 4.0, 2.0)], ), # Two text boxes side by side, text flows from first to second. CaseVFont( name="two_boxes", text=" ".join(generate_random_words(8, seed=30)), size=(424, 502), expected_dimensions=(5.607142, 6.686813), position_offset=(5.2, 453.9), text_boxes=[ TextBox(0.0, 0.0, 2.5, 1.5), TextBox(3.0, 0.0, 2.5, 1.5), ], ), # Offset text box (non-zero x, y origin). CaseVFont( name="offset_origin", text=" ".join(generate_random_words(1, seed=40)), size=(149, 68), expected_dimensions=(1.783654, 0.655220), position_offset=(-62.2, 93.9), text_boxes=[TextBox(1.0, -1.0, 3.0, 0.0)], ), # Very narrow box with combining characters. CaseVFont( name="narrow_combining", text=unicodedata.normalize("NFD", "\u00C0vatar \u00C8ve"), size=(170, 141), expected_dimensions=(2.079670, 1.667668), position_offset=(10.0, 82.5), text_boxes=[TextBox(0.0, 0.0, 1.0, 0.0)], ), ] class TestStyles(TestImageComparison_MixIn, unittest.TestCase): """Test per-character styling: bold, italic, underline, smallcaps.""" name_prefix = "styles." cases_vfont = [ CaseVFont( name="underline", text=( TextFormatCompose() .text("Golf ") .style(underline=True).text("Hotel") .style(underline=False).text(" India") ), size=(378, 66), expected_dimensions=(4.967720, 0.625412), position_offset=(7.0, 17.2), ), CaseVFont( name="smallcaps", text=( TextFormatCompose() .text("Juliet ") .style(smallcaps=True).text("Kilo") .style(smallcaps=False).text(" Lima") ), size=(366, 67), expected_dimensions=(4.793270, 0.652473), position_offset=(13.9, 19.4), ), CaseVFont( name="mixed", text=( TextFormatCompose() .style(bold=True).text("Mike") .style(bold=False).text(" ") .style(italic=True).text("November") .style(italic=False).text(" ") .style(underline=True).text("Oscar") .style(underline=False).text(" ") .style(smallcaps=True).text("Papa") ), size=(634, 65), expected_dimensions=(8.523564, 0.621948), position_offset=(5.6, 17.2), ), # All four font variants in a single string. CaseVFont( name="mixed_fonts", text=( TextFormatCompose() .style(bold=False, italic=False).text("Regular") .style(bold=True).text("Bold") .style(bold=False, italic=True).text("Italic") .style(bold=True, italic=True).text("BoldItalic") ), size=(640, 70), expected_dimensions=(8.606053, 0.689301), position_offset=(5.2, 21.9), ), # Tight kerning. CaseVFont( name="kern_tight", text=( TextFormatCompose() .style(kerning=-5.0).text("AV") .style(kerning=0.0).text("atar") ), size=(167, 56), expected_dimensions=(2.036487, 0.499313), position_offset=(10.0, 10.5), ), # Wide kerning. CaseVFont( name="kern_wide", text=( TextFormatCompose() .style(kerning=10.0).text("Hello") ), size=(156, 59), expected_dimensions=(1.875172, 0.528846), position_offset=(5.2, 10.5), ), ] class TestSize(TestImageComparison_MixIn, unittest.TestCase): """Test font size variations.""" name_prefix = "size." cases_vfont = [ CaseVFont( name="half", text=" ".join(generate_phonetic_alphabet(letters=("a", "b"))), size=(160, 45), expected_dimensions=(1.934753, 0.343407), position_offset=(10.0, 15.9), font_size=0.5, ), CaseVFont( name="double", text=" ".join(generate_phonetic_alphabet(letters=("a", "b"))), size=(578, 119), expected_dimensions=(7.739011, 1.373626), position_offset=(10.0, 33.7), font_size=2.0, ), ] class TestSpacing(TestImageComparison_MixIn, unittest.TestCase): """Test word, line, and character spacing variations.""" name_prefix = "spacing." cases_vfont = [ CaseVFont( name="word_tight", text=" ".join(generate_phonetic_alphabet(letters=("e", "h"))), size=(521, 59), expected_dimensions=(6.956732, 0.532280), position_offset=(5.2, 10.5), space_word=0.25, ), CaseVFont( name="word_wide", text=" ".join(generate_phonetic_alphabet(letters=("e", "h"))), size=(579, 59), expected_dimensions=(7.760303, 0.532280), position_offset=(5.2, 10.5), space_word=1.75, ), CaseVFont( name="line_tight", text="\n".join(generate_phonetic_alphabet(letters=("a", "d"))), size=(181, 167), expected_dimensions=(2.234203, 2.028846), position_offset=(10.0, 118.5), space_line=0.5, ), CaseVFont( name="line_wide", text="\n".join(generate_phonetic_alphabet(letters=("a", "d"))), size=(181, 491), expected_dimensions=(2.234203, 6.528846), position_offset=(10.0, 442.5), space_line=2.0, ), CaseVFont( name="character_tight", text=" ".join(generate_phonetic_alphabet(letters=("a", "d"))), size=(468, 70), expected_dimensions=(6.214801, 0.686813), position_offset=(10.0, 21.9), space_character=0.75, ), CaseVFont( name="character_wide", text=" ".join(generate_phonetic_alphabet(letters=("a", "d"))), size=(1041, 70), expected_dimensions=(14.176510, 0.686813), position_offset=(10.0, 21.9), space_character=1.5, ), ] class TestGeometry(TestImageComparison_MixIn, unittest.TestCase): """ Test geometry offset and extrude. """ name_prefix = "geometry." cases_vfont = [ CaseVFont( name="offset_positive", text=" ".join(generate_random_words(4, seed=50)), size=(599, 84), expected_dimensions=(7.873791, 0.726813), position_offset=(12.4, 29.1), geometry_offset=0.02, ), CaseVFont( name="offset_negative", text=" ".join(generate_random_words(4, seed=50)), size=(591, 65), expected_dimensions=(7.793791, 0.646813), position_offset=(8.0, 15.5), geometry_offset=-0.02, ), ] class TestGridLayouts(TestImageComparison_MixIn, unittest.TestCase): """ 4x4 grid of text boxes that text flows through, with non-standard word spacing and a very small font. Exercises multi-text-box layout in a single curve. """ name_prefix = "grid_layouts." cases_vfont = [ CaseVFont( name="grid_4x4", # Enough words to overflow through several boxes without making the # curve-fill triangulation pass slow on the test build. text=" ".join(generate_random_words(170, seed=42)), # 4x4 grid where each cell is 1.4 wide / 0.8 tall, laid out on a stride of # 2.0 / 1.4 - leaves a 0.6 horizontal and 0.6 vertical gap between boxes. text_boxes=[ TextBox(col * 2.0, -row * 1.4, 1.4, 0.8) for row in range(4) for col in range(4) ], align_x='JUSTIFY', align_y='TOP', space_word=1.25, font_size=globals_vfont.size / 5.0, resolution=2, size=(553, 376), expected_dimensions=(7.397940, 4.938049), position_offset=(10.0, 354.1), ), ] class TestOverflowWillOverflow(TestImageComparison_MixIn, unittest.TestCase): """Test overflow mode (text spills outside the text box).""" name_prefix = "overflow_will_overflow." cases_vfont = [ # Text overflows a small box. CaseVFont( name="spill_single", text=" ".join(generate_random_words(4, seed=50)), size=(179, 272), expected_dimensions=(2.206731, 3.497253), position_offset=(8.0, 226.5), text_boxes=[TextBox(0.0, 0.0, 2.0, 1.0)], overflow='NONE', ), # Multiline overflow past box height. CaseVFont( name="spill_multiline", text="\n".join(generate_random_words(5, seed=60)), size=(262, 345), expected_dimensions=(3.357143, 4.513050), position_offset=(7.0, 298.5), text_boxes=[TextBox(0.0, 0.0, 3.0, 1.5)], overflow='NONE', ), # Combining characters overflow. CaseVFont( name="spill_combining", text=unicodedata.normalize("NFD", "\u00C0vatar \u00C8ve \u0178olanda"), size=(204, 213), expected_dimensions=(2.543269, 2.667668), position_offset=(10.0, 154.5), text_boxes=[TextBox(0.0, 0.0, 2.0, 1.0)], overflow='NONE', ), # Alignment variations. CaseVFont( name="align_left", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.583791, 3.528846), position_offset=(7.0, 226.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 0.0)], overflow='NONE', align_x='LEFT', ), CaseVFont( name="align_center", text="\n".join(generate_random_words(4, seed=70)), size=(205, 275), expected_dimensions=(2.563874, 3.528846), position_offset=(12.1, 226.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 0.0)], overflow='NONE', align_x='CENTER', ), CaseVFont( name="align_right", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.584478, 3.528846), position_offset=(18.6, 226.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 0.0)], overflow='NONE', align_x='RIGHT', ), CaseVFont( name="align_justify", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.583791, 3.528846), position_offset=(7.0, 226.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 0.0)], overflow='NONE', align_x='JUSTIFY', ), CaseVFont( name="align_flush", text="\n".join(generate_random_words(4, seed=70)), size=(195, 275), expected_dimensions=(2.423077, 3.528846), position_offset=(7.0, 226.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 0.0)], overflow='NONE', align_x='FLUSH', ), # Vertical alignment variations. CaseVFont( name="valign_top", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.583791, 3.528846), position_offset=(7.0, 207.4), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='NONE', align_y='TOP', ), CaseVFont( name="valign_top_baseline", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.583791, 3.528846), position_offset=(7.0, 226.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='NONE', align_y='TOP_BASELINE', ), CaseVFont( name="valign_center", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.583791, 3.528846), position_offset=(7.0, 155.7), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='NONE', align_y='CENTER', ), CaseVFont( name="valign_bottom_baseline", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.583791, 3.528846), position_offset=(7.0, 118.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='NONE', align_y='BOTTOM_BASELINE', ), CaseVFont( name="valign_bottom", text="\n".join(generate_random_words(4, seed=70)), size=(207, 275), expected_dimensions=(2.583791, 3.528846), position_offset=(7.0, 104.0), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='NONE', align_y='BOTTOM', ), ] class TestOverflowWillScaleToFit(TestImageComparison_MixIn, unittest.TestCase): """Test scale-to-fit overflow mode (text scales down to fit the box).""" name_prefix = "overflow_will_scale_to_fit." cases_vfont = [ # Long text scaled to fit a narrow box. CaseVFont( name="narrow", text=" ".join(generate_random_words(6, seed=80)), size=(236, 73), expected_dimensions=(2.987429, 0.735459), position_offset=(9.1, 44.9), text_boxes=[TextBox(0.0, 0.0, 3.0, 1.0)], overflow='SCALE', ), # Multiline text scaled down. CaseVFont( name="multiline", text="\n".join(generate_random_words(4, seed=90)), size=(112, 115), expected_dimensions=(1.274640, 1.317394), position_offset=(10.0, 91.2), text_boxes=[TextBox(0.0, 0.0, 3.0, 1.5)], overflow='SCALE', ), # Text that already fits (no scaling needed). CaseVFont( name="fits", text=" ".join(generate_random_words(1, seed=100)), size=(232, 54), expected_dimensions=(2.932313, 0.465350), position_offset=(5.8, 10.4), text_boxes=[TextBox(0.0, 0.0, 3.0, 2.0)], overflow='SCALE', ), # Combining characters with scale-to-fit. CaseVFont( name="combining", text=unicodedata.normalize("NFD", "\u00C0vatar \u00C8ve \u0178olanda"), size=(141, 81), expected_dimensions=(1.675824, 0.833834), position_offset=(10.0, 46.2), text_boxes=[TextBox(0.0, 0.0, 2.0, 1.0)], overflow='SCALE', ), # Alignment variations. CaseVFont( name="align_left", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.645948, 0.882212), position_offset=(9.2, 64.1), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_x='LEFT', ), CaseVFont( name="align_center", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.640968, 0.882212), position_offset=(-57.0, 64.1), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_x='CENTER', ), CaseVFont( name="align_right", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.646119, 0.882212), position_offset=(-122.8, 64.1), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_x='RIGHT', ), CaseVFont( name="align_justify", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.645948, 0.882212), position_offset=(9.2, 64.1), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_x='JUSTIFY', ), CaseVFont( name="align_flush", text="\n".join(generate_random_words(4, seed=70)), size=(199, 84), expected_dimensions=(2.480769, 0.882212), position_offset=(9.2, 64.1), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_x='FLUSH', ), # Vertical alignment variations. CaseVFont( name="valign_top", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.645948, 0.882212), position_offset=(9.2, 59.3), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_y='TOP', ), CaseVFont( name="valign_top_baseline", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.645948, 0.882212), position_offset=(9.2, 64.1), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_y='TOP_BASELINE', ), CaseVFont( name="valign_center", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.645948, 0.882212), position_offset=(9.2, 59.9), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_y='CENTER', ), CaseVFont( name="valign_bottom_baseline", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.645948, 0.882212), position_offset=(9.2, 64.1), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_y='BOTTOM_BASELINE', ), CaseVFont( name="valign_bottom", text="\n".join(generate_random_words(4, seed=70)), size=(67, 84), expected_dimensions=(0.645948, 0.882212), position_offset=(9.2, 60.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 1.0)], overflow='SCALE', align_y='BOTTOM', ), ] class TestOverflowWillTruncate(TestImageComparison_MixIn, unittest.TestCase): """Test truncate overflow mode (text beyond the box is not rendered).""" name_prefix = "overflow_will_truncate." cases_vfont = [ # Explicit newlines, box tall enough for 3 lines, truncates the rest. CaseVFont( name="cut_lines", text="\n".join(generate_random_words(6, seed=110)), size=(168, 214), expected_dimensions=(2.046017, 2.686813), position_offset=(13.9, 165.9), text_boxes=[TextBox(0.0, 0.0, 3.0, 3.0)], overflow='TRUNCATE', ), # Long single line wraps into multiple lines, box truncates after 2 lines. CaseVFont( name="cut_wrap", text=" ".join(generate_random_words(12, seed=120)), size=(195, 136), expected_dimensions=(2.423077, 1.607143), position_offset=(7.0, 90.4), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', ), # Combining characters wrap then truncate. CaseVFont( name="cut_combining", text=unicodedata.normalize( "NFD", "\u00C0vatar \u00C8ve \u0178olanda r\u00E9sum\u00E9 na\u00EFve caf\u00E9", ), size=(170, 141), expected_dimensions=(2.079670, 1.667668), position_offset=(10.0, 82.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', ), # Alignment variations (wraps then truncates). CaseVFont( name="align_left", text=" ".join(generate_random_words(10, seed=130)), size=(153, 128), expected_dimensions=(1.835852, 1.497253), position_offset=(9.8, 82.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_x='LEFT', ), CaseVFont( name="align_center", text=" ".join(generate_random_words(10, seed=130)), size=(150, 128), expected_dimensions=(1.805289, 1.497253), position_offset=(-14.3, 82.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_x='CENTER', ), CaseVFont( name="align_right", text=" ".join(generate_random_words(10, seed=130)), size=(154, 128), expected_dimensions=(1.859204, 1.497253), position_offset=(-36.1, 82.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_x='RIGHT', ), CaseVFont( name="align_justify", text=" ".join(generate_random_words(10, seed=130)), size=(153, 128), expected_dimensions=(1.835852, 1.497253), position_offset=(9.8, 82.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_x='JUSTIFY', ), CaseVFont( name="align_flush", text=" ".join(generate_random_words(10, seed=130)), size=(200, 128), expected_dimensions=(2.497253, 1.497253), position_offset=(9.8, 82.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_x='FLUSH', ), # Vertical alignment variations (wraps then truncates). CaseVFont( name="valign_top", text=" ".join(generate_random_words(10, seed=130)), size=(153, 128), expected_dimensions=(1.835852, 1.497253), position_offset=(9.8, 63.4), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_y='TOP', ), CaseVFont( name="valign_top_baseline", text=" ".join(generate_random_words(10, seed=130)), size=(153, 128), expected_dimensions=(1.835852, 1.497253), position_offset=(9.8, 82.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_y='TOP_BASELINE', ), CaseVFont( name="valign_center", text=" ".join(generate_random_words(10, seed=130)), size=(153, 128), expected_dimensions=(1.835852, 1.497253), position_offset=(9.8, 83.7), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_y='CENTER', ), CaseVFont( name="valign_bottom_baseline", text=" ".join(generate_random_words(10, seed=130)), size=(153, 128), expected_dimensions=(1.835852, 1.497253), position_offset=(9.8, 118.5), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_y='BOTTOM_BASELINE', ), CaseVFont( name="valign_bottom", text=" ".join(generate_random_words(10, seed=130)), size=(153, 128), expected_dimensions=(1.835852, 1.497253), position_offset=(9.8, 104.0), text_boxes=[TextBox(0.0, 0.0, 2.5, 2.5)], overflow='TRUNCATE', align_y='BOTTOM', ), ] # ------------------------------------------------------------------------------ # Argument Parser def argparse_create() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--generate", action="store_true", help="Generate reference images instead of comparing (valid with --mode=BUFFER, VFONT, or ALL)", ) parser.add_argument( "--show-html", type=str, default=None, metavar="PATH", help="Write an HTML report of failures to PATH", ) parser.add_argument( "-k", "--keyword", type=str, default=None, metavar="PATTERN", help="Filter tests by name pattern (passed as -k to unittest)", ) parser.add_argument( "--use-vfont-render", action="store_true", help="Render VFont tests via an orthographic Cycles camera instead of the GPU triangle path", ) parser.add_argument( "--mode", choices=tuple(MODE_KINDS), default="BUFFER", help=( "Which rendering pipeline to test. CMake registers one test per mode " "(BUFFER, GPU, VFONT). ALL runs every kind in a single invocation - useful for manual runs." ), ) return parser # --------------------------------------------------------------------------- # Main def main() -> None: global USE_GENERATE_TEST_DATA, USE_VFONT_RENDER, VFONT_RENDER_DIR, SHOW_HTML, OUTPUT_DIR, FONTS_VFONT global VFONT_RENDER_CTX, gpu if "--" in sys.argv: argv = [sys.argv[0]] + sys.argv[sys.argv.index("--") + 1:] else: argv = sys.argv parser = argparse_create() args, remaining = parser.parse_known_args(argv) # Reference PNGs are authored by the BUFFER or VFONT pipelines; GPU mode # only ever validates against them, so --generate is not meaningful for GPU # alone. ALL is allowed: buffer/vfont references are written, then GPU # tests run as normal comparisons against the freshly generated references. if args.generate and args.mode == 'GPU': sys.exit("--generate is not valid with --mode=GPU (GPU validates against buffer references)") active_kinds = set(MODE_KINDS[args.mode]) attach_test_methods(args.mode) if args.keyword: remaining.extend(["-k", args.keyword]) USE_GENERATE_TEST_DATA = args.generate if args.use_vfont_render: USE_VFONT_RENDER = True SHOW_HTML = args.show_html or "" # GPU is needed for the GPU and VFont pipelines (off-screen rendering). gpu_needed = False if "gpu" in active_kinds: gpu_needed = True elif "vfont" in active_kinds: if not USE_VFONT_RENDER: gpu_needed = True if gpu_needed: # Expect failure. try: gpu.init() except SystemError as ex: if os.environ.get("WITHOUT_GPU"): gpu = None # Without this VFont won't work. if "vfont" in active_kinds: USE_VFONT_RENDER = True else: sys.exit("GPU initialization failed: {:s}".format(str(ex))) del gpu_needed # Load the bpy VectorFont once if any vfont tests are active. if "vfont" in active_kinds: FONTS_VFONT = VFontSet( regular=bpy.data.fonts.load(FONT_PATH_REGULAR), bold=bpy.data.fonts.load(FONT_PATH_BOLD), italic=bpy.data.fonts.load(FONT_PATH_ITALIC), bold_italic=bpy.data.fonts.load(FONT_PATH_BOLD_ITALIC), ) # Persistent dir for test images (--show-html) or a throwaway temp dir. output_ctx: contextlib.AbstractContextManager[str] if SHOW_HTML: output_dir_path = os.path.join(TEST_DIR, "output_" + args.mode.lower()) os.makedirs(output_dir_path, exist_ok=True) output_ctx = contextlib.nullcontext(output_dir_path) else: output_ctx = tempfile.TemporaryDirectory() # Temp dir in Blender's temp space for the Cycles render output PNG. vfont_tmp_ctx = ( tempfile.TemporaryDirectory(dir=bpy.app.tempdir) if (USE_VFONT_RENDER and "vfont" in active_kinds) else contextlib.nullcontext("") ) with output_ctx as output_dir, vfont_tmp_ctx as tmpdir: OUTPUT_DIR = output_dir VFONT_RENDER_DIR = tmpdir # Separate: VFontRenderContext.__init__ reads VFONT_RENDER_DIR. # Scene setup/teardown for Cycles camera renders. vfont_render_ctx = ( VFontRenderContext() if (USE_VFONT_RENDER and "vfont" in active_kinds) else contextlib.nullcontext(None) ) with vfont_render_ctx as VFONT_RENDER_CTX: unittest.main(argv=remaining, exit=False) if FONTS_VFONT is not None: for font in FONTS_VFONT: bpy.data.fonts.remove(font) FONTS_VFONT = None if SHOW_HTML: write_html_report(SHOW_HTML) if __name__ == "__main__": main()