Skip to content

Latest commit

 

History

History
844 lines (637 loc) · 29.4 KB

File metadata and controls

844 lines (637 loc) · 29.4 KB

Text-to-Path Conversion for svg2fbf

Overview

This document outlines the requirements and implementation plan for adding text-to-path conversion functionality to svg2fbf. This feature will convert SVG text elements to vector paths, enabling deduplication and significantly reducing FBF.SVG file sizes.

Problem Statement

Current Behavior

svg2fbf currently excludes text elements from deduplication (see src/svg2fbf.py:1033):

# "text",  # WHY: Text elements cannot be converted to <use> references
#          # in FBF format because embedded SVG fonts don't work when
#          # text is referenced via <use>. Since FBF requires all
#          # resources to be embedded (no external loading), text
#          # elements must remain inline to ensure fonts render correctly.

Consequences:

  • Text elements are duplicated across every frame
  • Large file sizes when text appears in multiple frames
  • Font embedding required for proper rendering
  • Text cannot be deduplicated via <use> references

Proposed Solution

Convert text elements to vector paths before deduplication:

Benefits:

  • Paths can be deduplicated via <use> references
  • No font embedding needed
  • Resolution-independent vector data
  • Significant file size reduction for repeated text
  • Consistent rendering across all SVG renderers

Implementation Requirements

1. Dependencies

Add these Python libraries to pyproject.toml:

[project]
dependencies = [
    # ... existing dependencies ...
    "fontTools>=4.47.0",        # Font parsing and glyph extraction
    "python-bidi>=0.4.2",       # Bidirectional text support (Arabic, Hebrew)
    "svgpathtools>=1.6.1",      # SVG path manipulation
]

2. Core Functionality

2.1 Text Element Detection

Identify text elements in SVG:

def find_text_elements(root: ET.Element) -> list[tuple[ET.Element, ET.Element]]:
    """
    Find all text elements in SVG.

    Returns:
        List of (parent, text_element) tuples for replacement
    """
    text_elements = []
    for parent in root.iter():
        for child in parent:
            if child.tag.endswith('}text') or child.tag == 'text':
                text_elements.append((parent, child))
    return text_elements

2.2 Font Loading and Caching

from fontTools.ttLib import TTFont
from pathlib import Path

class FontCache:
    """Cache loaded fonts to avoid repeated parsing."""

    def __init__(self):
        self._fonts: dict[str, TTFont] = {}

    def get_font(self, font_family: str, font_style: str = 'normal',
                 font_weight: str = 'normal') -> TTFont:
        """
        Load font from system or embedded font.

        Args:
            font_family: Font family name (e.g., "Arial", "Times New Roman")
            font_style: normal, italic, oblique
            font_weight: normal, bold, 100-900

        Returns:
            Loaded TTFont instance
        """
        key = f"{font_family}:{font_style}:{font_weight}"

        if key not in self._fonts:
            font_path = self._resolve_font_path(font_family, font_style, font_weight)
            self._fonts[key] = TTFont(font_path)

        return self._fonts[key]

    def _resolve_font_path(self, family: str, style: str, weight: str) -> Path:
        """Resolve font family name to system font path."""
        # Platform-specific font resolution
        # macOS: /System/Library/Fonts, /Library/Fonts, ~/Library/Fonts
        # Linux: /usr/share/fonts, ~/.fonts
        # Windows: C:\Windows\Fonts
        pass

2.3 Text-to-Path Conversion

from fontTools.pens.svgPathPen import SVGPathPen
from bidi.algorithm import get_display

def text_to_path(text_elem: ET.Element, font_cache: FontCache) -> ET.Element:
    """
    Convert text element to path element.

    Args:
        text_elem: SVG text element
        font_cache: Font cache for efficient loading

    Returns:
        SVG path element with converted text
    """
    # 1. Extract text content and attributes
    text_content = ''.join(text_elem.itertext())
    x = float(text_elem.get('x', 0))
    y = float(text_elem.get('y', 0))
    font_family = text_elem.get('font-family', 'Arial')
    font_size = parse_font_size(text_elem.get('font-size', '16'))
    font_style = text_elem.get('font-style', 'normal')
    font_weight = text_elem.get('font-weight', 'normal')

    # 2. Handle bidirectional text (Arabic, Hebrew)
    display_text = get_display(text_content)

    # 3. Load font
    font = font_cache.get_font(font_family, font_style, font_weight)
    glyph_set = font.getGlyphSet()
    units_per_em = font['head'].unitsPerEm

    # 4. Convert glyphs to path
    path_pen = SVGPathPen(glyph_set)
    advance_x = 0

    for char in display_text:
        # Get glyph for character
        cmap = font.getBestCmap()
        if ord(char) not in cmap:
            continue  # Skip unmapped characters

        glyph_name = cmap[ord(char)]
        glyph = glyph_set[glyph_name]

        # Draw glyph outline at current position
        path_pen.moveTo((x + advance_x, y))
        glyph.draw(path_pen)

        # Advance to next character position
        advance_x += glyph.width * (font_size / units_per_em)

    # 5. Create path element
    path_elem = ET.Element('path')
    path_elem.set('d', path_pen.getCommands())

    # 6. Copy style attributes from text to path
    for attr in ['fill', 'stroke', 'stroke-width', 'opacity', 'class', 'id']:
        if attr in text_elem.attrib:
            path_elem.set(attr, text_elem.get(attr))

    return path_elem

2.4 SVG Transformation

def convert_text_to_paths_in_svg(svg_root: ET.Element, font_cache: FontCache) -> None:
    """
    Convert all text elements in SVG to paths (in-place).

    Args:
        svg_root: Root element of SVG document
        font_cache: Font cache for efficient loading
    """
    text_elements = find_text_elements(svg_root)

    for parent, text_elem in text_elements:
        try:
            # Convert text to path
            path_elem = text_to_path(text_elem, font_cache)

            # Replace text element with path
            idx = list(parent).index(text_elem)
            parent.remove(text_elem)
            parent.insert(idx, path_elem)

        except Exception as e:
            # Log warning but continue processing
            print(f"Warning: Failed to convert text element: {e}", file=sys.stderr)
            # Keep original text element

3. Integration Points

3.1 Command-Line Option

Add optional flag to enable text-to-path conversion:

@click.option(
    '--convert-text-to-paths',
    is_flag=True,
    default=False,
    help='Convert text elements to vector paths before processing. '
         'Enables deduplication of text and removes font dependency.'
)
def main(convert_text_to_paths: bool, ...):
    pass

3.2 Processing Pipeline

Insert text-to-path conversion before deduplication:

def process_svg_files(input_dir: Path, convert_text_to_paths: bool, ...) -> None:
    """Main processing pipeline."""

    font_cache = FontCache() if convert_text_to_paths else None

    for svg_file in sorted(input_dir.glob('*.svg')):
        tree = ET.parse(svg_file)
        root = tree.getroot()

        # 1. Convert text to paths (NEW STEP)
        if convert_text_to_paths:
            convert_text_to_paths_in_svg(root, font_cache)

        # 2. Existing processing steps
        normalize_svg(root)
        deduplicate_elements(root)
        # ... etc ...

3.3 Update Deduplication

Remove text from excluded elements (if conversion is enabled):

# In deduplicate_elements()
EXCLUDED_TAGS = {
    "defs", "symbol", "marker", "clipPath", "mask",
    "linearGradient", "radialGradient", "pattern",
    # Remove "text" from exclusion list when conversion enabled
}

if not convert_text_to_paths:
    EXCLUDED_TAGS.add("text")

Edge Cases and Considerations

1. Font Resolution

Challenge: System fonts vary across platforms

Solutions:

  • Provide --font-dir option to specify custom font directory
  • Support embedded fonts from SVG <defs> section
  • Fallback to default system fonts
  • Error handling for missing fonts

2. Complex Text Features

Not Initially Supported:

  • <tspan> with different styles (requires per-span conversion)
  • Text on path (<textPath>)
  • Text decoration (underline, strikethrough) - preserve as separate paths
  • Vertical text (writing-mode="tb")

Future Enhancements:

  • Add support for <tspan> by processing each span separately
  • Convert <textPath> by sampling path and positioning glyphs
  • Generate decoration paths for underline/strikethrough

3. BiDi and Complex Scripts

Supported (via python-bidi):

  • Arabic (right-to-left)
  • Hebrew (right-to-left)
  • Mixed LTR/RTL text

Not Supported (requires more complex shaping):

  • Devanagari ligatures
  • Thai/Khmer vowel positioning
  • Arabic contextual forms (requires HarfBuzz-level shaping)

Recommendation: For complex scripts, use external text-to-path tool (text2path Rust tool) as preprocessing step.

4. Performance

Optimizations:

  • Cache loaded fonts (FontCache class)
  • Process text conversion in parallel (multiprocessing)
  • Only convert text when --convert-text-to-paths is specified

Benchmarks to Add:

  • Time to convert 100 frames with repeated text
  • File size reduction for text-heavy animations
  • Memory usage for large font files

5. Backward Compatibility

Default Behavior: Text conversion is opt-in via --convert-text-to-paths

Rationale:

  • Preserves existing workflows
  • Users may prefer editable text in some cases
  • Font licensing concerns (paths cannot be reverse-engineered to fonts)

Testing Requirements

Unit Tests

def test_text_to_path_simple():
    """Test basic text-to-path conversion."""
    text_elem = ET.fromstring('<text x="10" y="20" font-size="16">Hello</text>')
    font_cache = FontCache()
    path_elem = text_to_path(text_elem, font_cache)

    assert path_elem.tag == 'path'
    assert 'd' in path_elem.attrib
    assert path_elem.get('d').startswith('M')  # Path starts with moveTo

def test_text_to_path_arabic():
    """Test Arabic bidirectional text."""
    text_elem = ET.fromstring('<text>مرحبا</text>')
    font_cache = FontCache()
    path_elem = text_to_path(text_elem, font_cache)

    assert path_elem.tag == 'path'
    # Verify RTL rendering

def test_font_cache():
    """Test font caching functionality."""
    cache = FontCache()
    font1 = cache.get_font('Arial', 'normal', 'normal')
    font2 = cache.get_font('Arial', 'normal', 'normal')

    assert font1 is font2  # Same instance

Integration Tests

def test_convert_text_in_svg_file(tmp_path):
    """Test end-to-end conversion in SVG file."""
    svg_content = '''<?xml version="1.0"?>
    <svg xmlns="http://www.w3.org/2000/svg">
        <text x="10" y="20" font-size="16">Test</text>
    </svg>'''

    svg_file = tmp_path / "test.svg"
    svg_file.write_text(svg_content)

    tree = ET.parse(svg_file)
    root = tree.getroot()
    font_cache = FontCache()
    convert_text_to_paths_in_svg(root, font_cache)

    # Verify text element replaced with path
    assert len(root.findall('.//{http://www.w3.org/2000/svg}text')) == 0
    assert len(root.findall('.//{http://www.w3.org/2000/svg}path')) == 1

Test Sessions

Create test session with text-heavy frames:

tests/sessions/test_session_TEXT_20frames/
├── input_frames/
│   ├── frame_0001.svg  # Text "Hello World" at (10, 20)
│   ├── frame_0002.svg  # Same text at (10, 20)
│   ├── ...
│   └── frame_0020.svg  # Same text at (10, 20)
└── runs/
    └── <timestamp>_convert_text/
        ├── output.fbf.svg
        ├── test_results.json
        └── stats.txt  # Should show significant size reduction

Expected Results:

  • Text deduplicated via <use> references
  • File size: ~20% of original (text converted to single <path> in <defs>)
  • All frames render identically

Implementation Phases

Phase 1: Basic Text Conversion (MVP)

  • Add dependencies (fontTools, python-bidi)
  • Implement FontCache class
  • Implement text_to_path() for simple text
  • Add --convert-text-to-paths CLI option
  • Integrate into processing pipeline
  • Add unit tests
  • Document usage

Phase 2: Enhanced Support

  • Support <tspan> elements
  • Handle font styles (bold, italic)
  • Add custom font directory support
  • Improve font resolution across platforms
  • Add performance benchmarks

Phase 3: Advanced Features

  • Support text on path (<textPath>)
  • Handle text decorations (underline, strikethrough)
  • Parallel processing for large SVG sets
  • Complex script support (via HarfBuzz Python bindings)

Documentation Updates

Update these files:

  1. README.md - Add text-to-path feature to features list
  2. DEVELOPMENT.md - Document text-to-path conversion implementation
  3. docs/USAGE.md - Add --convert-text-to-paths usage examples
  4. tests/README.md - Document text conversion test sessions

SVG Specification Compliance (2025-11-19)

Critical Finding: text-align vs text-anchor

Issue Discovered: Many SVG authoring tools (including Inkscape) may use CSS text-align property instead of SVG text-anchor attribute.

SVG 2.0 Specification (W3C):

  • text-anchor is the ONLY alignment property for SVG <text> elements
  • text-align is a CSS property that does NOT apply to SVG text elements
  • Valid text-anchor values: start (default), middle, end

Browser Behavior (tested 2025-11-19):

  • ✅ Browsers ignore text-align:center in SVG <text> elements
  • ✅ Browsers only honor text-anchor XML attribute
  • ✅ Default is text-anchor="start" (left-aligned for LTR text)

Inkscape Behavior:

  • ✅ Inkscape is spec-compliant
  • ✅ Inkscape ignores text-align CSS property
  • ✅ Renders as text-anchor="start" when attribute not present

Malformed SVG Files

Common mistake:

<!-- WRONG: text-align doesn't work in SVG -->
<text style="text-align:center" x="200" y="100">Text</text>

Correct SVG:

<!-- CORRECT: use text-anchor XML attribute -->
<text text-anchor="middle" x="200" y="100">Text</text>

Handling Malformed Files

No preprocessing needed! The text-to-path algorithm automatically handles both correct and malformed syntax:

# In text_to_path():
text_anchor = text_elem.get('text-anchor', 'start')

# Handle malformed SVG files that use text-align instead of text-anchor
style = text_elem.get('style', '')
text_align_match = re.search(r'text-align:\s*(center|left|right)', style)
if text_align_match and text_anchor == 'start':  # Only if text-anchor not explicitly set
    text_align_map = {'center': 'middle', 'left': 'start', 'right': 'end'}
    text_anchor = text_align_map.get(text_align_match.group(1), 'start')

Rationale: Since we're converting to paths anyway, we can apply the correct alignment regardless of whether the source SVG uses correct (text-anchor) or incorrect (text-align) syntax. This makes the tool more robust and user-friendly.

Implementation Results

Positioning Accuracy:

  • Average error: 0.0013 px (sub-pixel precision)
  • Maximum error: 0.0024 px
  • ✅ Perfect geometric accuracy

Visual Comparison:

  • Pixel difference: ~7% (with 53 text elements)
  • Cause: Anti-aliasing differences between text and path rendering engines
  • Expected and acceptable (paths are geometrically identical)

Production Implementation

Main Implementation: svg-text2path Library (Recommended)

The primary text-to-path conversion in svg2fbf uses the external svg-text2path library, integrated via the --text2path CLI flag.

Installation:

svg-text2path is a standard runtime dependency of svg2fbf — it installs automatically with the regular svg2fbf package, no extras syntax needed:

uv tool install svg2fbf

Usage:

svg2fbf -i frames/ -o output/ -f animation.fbf.svg --text2path

Features:

  • ✅ HarfBuzz text shaping (production-quality rendering)
  • ✅ Complex script support (Arabic, Indic, CJK, etc.)
  • ✅ Proper ligature handling
  • ✅ Bidirectional text support
  • ✅ SVG validation with Bun
  • ✅ 8 decimal precision (configurable)

Legacy Implementation: scripts_dev/text_to_path.py

Note: This standalone tool has been moved to scripts_dev/ and is kept for reference only. For production use, prefer the --text2path flag which uses the svg-text2path library.

Status: 🗄️ Reference implementation (moved from src/svg2fbf/text_to_path.py)

Features Implemented:

  • ✅ Font glyph extraction via FontTools
  • ✅ TTF/OTF/TTC (TrueType Collection) support
  • ✅ 6 decimal precision (optimized from 28)
  • ✅ Cross-platform font discovery (macOS, Linux, Windows)
  • ✅ CLI interface with in-place editing and backup
  • ✅ Font fallback for missing fonts
  • ✅ Proper SVG namespace handling

Test Results

Test Case: "FBF•SVG" Text

  • Font: Futura Medium, 87.4256px
  • Test File: /tmp/test_fbf_text.svg
  • Characters: F, B, F, •, S, V, G

Results:

Found 7 text element(s)
✓ Converted: text_F
✓ Converted: text_B
✓ Converted: text_F2
✓ Converted: text_dot
✓ Converted: text_S
✓ Converted: text_V
✓ Converted: text_G

Pixel comparison:
  Total pixels: 480,000
  Different pixels: 0
  Difference: 0.00%

✓ SUCCESS: Difference is below 5% threshold!

File Size:

  • Original (text): 1.6KB
  • Converted (paths): 4.9KB
  • Increase: 3x (acceptable for portability)

Usage

# Convert text to paths
python scripts_dev/text_to_path.py input.svg output.svg

# Convert in-place
python scripts_dev/text_to_path.py input.svg --in-place

# Convert with backup
python scripts_dev/text_to_path.py input.svg --in-place --backup

# Example: Process FBF header
python scripts_dev/text_to_path.py \
    assets/panther_bird_header.fbf.svg \
    assets/panther_bird_header_paths.fbf.svg

Known Issues and Limitations

Based on extensive testing with complex scenarios:

1. Symbol Fonts (39% of visual gap in complex tests)

Issue: Webdings, Wingdings use non-standard character mappings Impact: Different glyphs may be selected vs Inkscape Workaround: Use standard fonts where possible

2. Complex Scripts (37% of visual gap)

Issue: Chinese/CJK characters need proper font selection Impact: Font fallback may select incorrect fonts Current: Works but requires fonts with CJK glyphs Future: Implement HarfBuzz text shaping

3. Greek Text (13% of visual gap)

Issue: Font fallback differences for non-Latin scripts Impact: Minor visual differences Acceptable: Within tolerance for most use cases

4. Character Spacing

Issue: Currently uses rough estimate (font_size * 0.6) Impact: Spacing may not match original exactly Future: Use actual glyph advance widths from font metrics

5. TTC Font Collections

Issue: Multiple fonts in one .ttc file Current: Uses first font in collection (fontNumber=0) Future: Match font weight/style to select correct variant

FBF Animation Considerations

Important: For FBF.SVG animation files, text conversion converts text in all frames. However:

  1. Visual comparison is complex - Must extract and compare individual frames
  2. Frame-by-frame differences - Each frame may have different text content
  3. Deduplication benefits - Converted paths can be deduplicated via <use> references

Recommendation: Test text-to-path conversion on individual frames first, then apply to full FBF animation.

Decimal Precision Analysis

Early Version: 28 decimal places

  • Example: -320.7300597363279166529537178576
  • File size: 98,442 chars for single path
  • Problems: Bloat, potential rendering artifacts

Production Version: 6 decimal places

  • Example: -320.73
  • File size: 23,819 chars for same path (4x smaller)
  • Precision: 0.000001 unit = sub-pixel accuracy

Comparison with Inkscape:

  • Inkscape uses 6-8 decimal places
  • Our implementation matches Inkscape precision
  • 0.00% pixel difference for simple fonts

Visual Comparison Methodology (2025-11-20)

Critical Finding: Raw pixel comparison shows ~13% difference even when paths are geometrically identical.

Root Cause: Anti-aliasing differences between:

  • Text rendering: Uses font hinting and sub-pixel positioning
  • Path rendering: Uses geometric anti-aliasing on vector outlines

Solution: Use tolerance threshold when comparing pixels to filter out anti-aliasing gradients.

Threshold-Based Comparison

from PIL import Image
import numpy as np

def compare_with_threshold(img1_path: str, img2_path: str, threshold: int = 30) -> float:
    """
    Compare two images with anti-aliasing tolerance.

    Args:
        img1_path: Path to first image (text version)
        img2_path: Path to second image (paths version)
        threshold: Pixel difference threshold (0-255, default: 30)
                  Pixels differing by <= threshold are considered identical

    Returns:
        Percentage of significantly different pixels
    """
    img1 = Image.open(img1_path).convert('RGB')
    img2 = Image.open(img2_path).convert('RGB')

    arr1 = np.array(img1, dtype=np.int32)
    arr2 = np.array(img2, dtype=np.int32)

    # Calculate per-channel absolute difference
    diff = np.abs(arr1 - arr2)

    # Max difference across RGB channels for each pixel
    max_channel_diff = np.max(diff, axis=2)

    # Count pixels exceeding threshold
    total_pixels = arr1.shape[0] * arr1.shape[1]
    significant_diff = max_channel_diff > threshold
    different_pixels = np.sum(significant_diff)

    return (different_pixels / total_pixels) * 100

Validation Results (2025-11-20)

Test Case: "FBF•SVG" header (7 characters, Futura Medium 87.4256px)

Threshold Different Pixels Percentage Status
1/255 (raw) 62,259 12.970% ❌ Too high
30/255 1,850 0.385% PASS
35/255 1,793 0.374% ✅ PASS
40/255 1,648 0.343% ✅ PASS
50/255 1,306 0.272% ✅ PASS

Conclusion:

  • Threshold 30/255 achieves 0.385% difference (meets <0.4% requirement)
  • ✅ Paths are geometrically identical to Inkscape's conversion
  • ✅ Tool is production-ready with validated comparison methodology

Comparison with Inkscape

To verify geometric accuracy, the same text was converted using:

  1. Our tool: python scripts_dev/text_to_path.py
  2. Inkscape: Text → Path to Path (Shift+Ctrl+C)

Result: Both produce identical path coordinates (verified by direct SVG comparison)

Example - Letter "F":

<!-- Our tool -->
<path d="M 485.596497 311.882547 L 462.630202 311.882547 L 462.630202 327.7199 ..." />

<!-- Inkscape -->
<path d="m 485.5965,311.88255 h -22.9663 v 15.83735 h 22.15522 ..." />

After normalizing relative→absolute coordinates, paths are mathematically equivalent.

Testing Tool

A standardized comparison script is available at tests/compare_text_to_path.py:

# Compare with default threshold (30/255)
python tests/compare_text_to_path.py text.png paths.png

# Compare with custom threshold
python tests/compare_text_to_path.py text.png paths.png --threshold 40

# Test range of thresholds
python tests/compare_text_to_path.py text.png paths.png --range 1 50

# Specify custom requirement
python tests/compare_text_to_path.py text.png paths.png --requirement 0.5

Validation Results:

  • Our tool vs Inkscape: 0.000% difference (virtually identical)
  • Our tool vs original text: 0.385% at 30/255 threshold (meets <0.4% requirement)

Production Test - FBF Animation Header:

python scripts_dev/text_to_path.py \
    assets/panther_bird_header.fbf.svg \
    /tmp/panther_bird_header_paths.fbf.svg

Results:

  • 79 text elements converted (0 failures)
  • ✅ All "FBF•SVG" title text converted across all frames
  • ✅ All badge text (version, compatibility, license) converted
  • File size: 902K → 941K (4% increase due to path verbosity)
    • Note: Size increase is expected - paths are more verbose than font references
    • After integration with svg2fbf deduplication, file size will significantly decrease
    • Repeated text will be deduplicated via <use> references

Next Steps

High Priority:

  1. ✅ Reduce decimal precision (28 → 6) - DONE
  2. ✅ Use actual glyph advance widths for spacing - DONE
  3. ✅ Validate with threshold-based comparison - DONE (0.385% @ 30/255)
  4. ✅ Create standardized testing tool - DONE
  5. ⏳ Add HarfBuzz integration for complex scripts

Medium Priority: 5. ⏳ Implement relative path coordinates (m, l vs M, L) 6. ⏳ Font fallback chain using fontconfig 7. ⏳ TTC font variant selection based on weight/style

Low Priority: 8. ⏳ Support <textPath> (text on curved paths) 9. ⏳ Vertical text (writing-mode) 10. ⏳ Path optimization (merge segments, simplify curves)

Test Coverage (TRDD-c2a3199d)

The text→path conversion is exercised end-to-end inside the Docker E2E suite (scripts/test_release_clean.sh → run inside a clean python:3.12-slim image with fonts-dejavu + fonts-liberation preinstalled so glyph metrics are deterministic across runners):

  • T_setup / T11 — installs the svg-bbox harness via npm, runs svg2fbf --text2path against the 5-frame fixture in tests/fixtures/e2e/text_frames/, and verifies an FBF.SVG is produced without errors.
  • T12 — per-frame visual diff of the fresh FBF against the committed golden tests/fixtures/e2e/text_frames/expected.fbf.svg. Each frame is extracted from BOTH FBFs as a standalone SVG, then compared via sbb-compare --json with a per-pixel-channel threshold of 32/256 and an image-wide diffPercentage budget of 3 % per frame. Used to be byte-exact, but the system DejaVu glyph table drifts by sub-pixel amounts across fresh apt-get install fonts-dejavu runs, making byte-exact non-deterministic. The pixel-level invariant ("the FBF still LOOKS like the golden") tolerates legitimate font-coordinate drift while still flagging real regressions.
  • T13 — per-frame visual diff: each input fixture SVG is compared against its corresponding extracted-from-FBF SVG via sbb-compare --json, with a per-pixel-channel threshold of 32/256 and an image-wide diffPercentage budget of 3 % per frame. The two thresholds are independent: the per-pixel one defines what counts as "different"; the image-wide one bounds how many such pixels are tolerated. T13 calibrates against the golden at ~1.8 %, leaving healthy headroom for legitimate AA-fringe drift between hinted-text rendering and path-rendering of the same glyphs.

The fixtures intentionally exercise text + textPath (text laid out along a static curve, no animation) at varied positions, rotations, and transforms. Both hinted (<text>) and unhinted (<path>) renderings go through sbb-compare's internal Chrome rasteriser so the comparison is apples-to-apples on the same renderer.

Tooling specific to text→path testing:

  • scripts/visual_diff_fbf_vs_golden.py — T12 harness (extracts each frame from BOTH the produced FBF and the golden FBF, then runs sbb-compare --json per pair).
  • scripts/visual_diff_text_frames.py — T13 harness.
  • scripts/extract_fbf_frame.py — XML-level extraction of FRAME0000N + SHARED_DEFINITIONS into a standalone SVG (per-frame comparison without animation playback).
  • scripts/pin_fbf_frame_to_png.py — calibration utility that pins PROSKENION's xlink:href to frame N, drops the <animate> child, and renders the full FBF wrapper to PNG via sbb-svg2png. Use this when you need to verify the golden FBF is a faithful conversion of the original input frame, with full ancestor inheritance preserved.
  • scripts/regen_text_frames_golden.sh — regenerate tests/fixtures/e2e/text_frames/expected.fbf.svg inside Docker (mirrors the T13 image: python:3.12-slim + fonts-dejavu + fonts-liberation). Even with T12 now using visual diff, the golden remains platform-pinned: a Linux-generated golden is what the Docker T12 compares against, so the regen still must happen inside Linux to keep the calibrated baseline near 0 % rather than the ~1-2 % cross-platform AA drift floor. Never regenerate this file on the macOS host directly.

The non-text2path byte-exact tests (host pytest test_e2e_byte_exact and Docker E2E byte-exact step) strip the two version-pinned lines (<!-- FILE GENERATED BY svg2fbf vN --> and <fbf:generatorVersion>vN</…>) before comparing — they are runtime metadata, not part of the wire format the test polices. This means the non-text2path golden does not need regeneration on every alpha→beta→rc→stable promotion; only on intentional wire-format changes. T12 (text2path) is no longer byte-exact, so the pin doesn't apply to the text2path golden.

References