3783 lines
135 KiB
Python
3783 lines
135 KiB
Python
# 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_<kind>_<case> 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(
|
|
"<html><head><meta charset='utf-8'>\n"
|
|
"<title>BLF Test Report</title>\n"
|
|
"</head><body bgcolor='#333' text='white'>\n"
|
|
"<h1>BLF Test Report</h1>\n"
|
|
)
|
|
|
|
# Summary.
|
|
fh.write("<ul>\n")
|
|
for kind, entries in by_kind.items():
|
|
count_kind = len(entries)
|
|
count_passed = sum(1 for e in entries if e.passed)
|
|
color = "green" if count_passed == count_kind else "red"
|
|
fh.write(
|
|
"<li><a href='#kind-{kind_lower:s}'>{kind:s}</a>"
|
|
" - <font color='{color:s}'>{passed:d}/{total:d} passed</font></li>\n".format(
|
|
kind_lower=kind,
|
|
kind=kind.upper(),
|
|
color=color,
|
|
passed=count_passed,
|
|
total=count_kind,
|
|
)
|
|
)
|
|
fh.write("</ul>\n")
|
|
|
|
for kind_idx, (kind, entries) in enumerate(by_kind.items()):
|
|
if kind_idx > 0:
|
|
fh.write("<hr>\n")
|
|
count_failed_kind = sum(1 for e in entries if not e.passed)
|
|
count_kind = len(entries)
|
|
fh.write(
|
|
"<h1><a name='kind-{kind_lower:s}'>{kind:s}</a>"
|
|
" ({passed:d}/{total:d} passed)</h1>\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("</table>\n<hr>\n")
|
|
fh.write("<h2>{:s}</h2>\n<table>\n".format(entry.group))
|
|
current_group = entry.group
|
|
if not entry.passed:
|
|
status = "<b><font color='red'>FAIL</font></b>"
|
|
else:
|
|
status = "<b><font color='green'>PASS</font></b>"
|
|
|
|
# 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", "<br>")
|
|
diff_uri = img_uri(entry.diff_path) if entry.diff_path else ""
|
|
diff_cell = (
|
|
" <td><small>Difference (10x)</small><br><img src='{:s}'></td>\n".format(diff_uri)
|
|
if diff_uri else ""
|
|
)
|
|
fh.write(
|
|
"<tr><td colspan='3'><h3>{name:s} {status:s}</h3>"
|
|
"<table bgcolor='black' cellpadding='4'><tr><td><code>{text:s}</code></td></tr></table>"
|
|
"<pre>{output:s}</pre></td></tr>\n"
|
|
"<tr>\n"
|
|
" <td><small>Before (reference)</small><br><img src='{ref:s}'></td>\n"
|
|
" <td><small>After (test output)</small><br><img src='{test:s}'></td>\n"
|
|
"{diff:s}"
|
|
"</tr>\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("</table>\n")
|
|
|
|
fh.write(
|
|
"<hr class='section'>\n"
|
|
"<p>{count_passed:d}/{count_total:d} passed (across all kinds)</p>\n"
|
|
"</body></html>\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_<name>`` (which also
|
|
runs the dimensions check) and ``test_gpu_<name>``. ``cases_vfont`` produces
|
|
``test_vfont_<name>``. They are selectable via the standard unittest CLI:
|
|
|
|
./blender.bin --background --factory-startup --python <test_file>.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()
|