Skip to content

foreignObject first-line baseline misalignment: cap-height vs baseline reference #275

@kyamagu

Description

@kyamagu

Summary

With tests/fixtures/texts/style-leading-2.psd, we observe slightly different text baselines between Photoshop and SVG foreignObject renderings. Specifically, the first line in SVG foreignObject is positioned upwards and overflows at the top of the div container.

Root Cause

The issue stems from a fundamental difference between Photoshop's baseline calculation and CSS's inline box baseline model:

Photoshop (SVG with dominant-baseline="hanging")

  • Reference point: Cap-height (top of capital letters) at the top edge
  • First line starts with cap-height at y=0 (or close to it)
  • Baseline is positioned below the cap-height by the font's ascent value

CSS foreignObject (with line-height and inline-block)

  • Reference point: Baseline centered in the line box
  • With line-height: 16px, first baseline is at 16/2 = 8px from top
  • With current margin-top: -8px compensation, baseline moves to 8-8 = 0px
  • However, cap-height is above the baseline by ~70-75% of font-size

The Core Problem

The current margin-top compensation formula:

margin_top_compensation = -(leading - font_size) / 2

This assumes that removing half-leading will align the baselines. However:

  1. CSS baseline is centered in the line-box (at line-height/2)
  2. Photoshop's hanging baseline puts the cap-height (not baseline) at the top
  3. Cap-height ≠ Baseline - cap-height is typically ~70-75% of font-size above the baseline
  4. This creates a systematic offset of approximately 0.7-0.75 * font_size

Example with Arial (style-leading-2.psd)

Font size: 32px
Leading: 16px
Typical cap-height ratio: 0.715

CSS foreignObject (current):
  - Baseline at: 0px (after margin-top: -8px)
  - Cap-height at: ~-22.9px (overflows above container)

Photoshop hanging baseline:
  - Cap-height at: ~0px
  - Baseline at: ~+22.9px

Vertical offset difference: ~22.9px

Test Case

File: tests/fixtures/texts/style-leading-2.psd

Photoshop settings:

  • Font: Arial, 32px
  • Leading: 16px (manual, not auto)
  • Bounding box: 240×119.43px
  • Expected lines: 7.5 (119.43 / 16)

Current SVG output:

<foreignObject x="0" y="0.57" width="240" height="119.43">
  <div style="width: 240px; height: 119.43px; margin: 0; padding: 0; overflow: hidden; box-sizing: border-box">
    <p style="margin: 0; padding: 0; line-height: 16px; margin-top: -8px">
      <span style="display: inline-block; line-height: 16px; font-family: 'Arial'; font-size: 32px">
        Lorem ipsum dolor sit amet...
      </span>
    </p>
  </div>
</foreignObject>

Observed issue:

  • First line overflows at the top of the container
  • Text appears shifted upward compared to Photoshop

Related Issues & Context

The recent fixes (#274, #271) successfully addressed the line-height expansion issue where spans with font-size > line-height were expanding the parent line box. However, the first-line baseline positioning discrepancy remains due to the cap-height vs baseline difference.

Technical Details

Current Implementation

Location: src/psd2svg/core/text.py:759-802

# Line height and margin-top compensation calculation
leading = paragraph.compute_leading()
margin_top_compensation = 0.0

if leading > 0:
    styles["line-height"] = svg_utils.num2str_with_unit(leading)
    
    # Calculate half-leading compensation for all paragraphs
    # CSS line-height centers text within a line box, adding unwanted space
    # above each line. We calculate the negative margin as:
    # margin-top = -(leading - font_size) / 2
    if paragraph.spans:
        first_span = paragraph.spans[0]
        font_size = first_span.style.font_size
        if leading > font_size:
            # Calculate half-leading compensation
            margin_top_compensation = -(leading - font_size) / 2

CSS Inline Formatting Context

The issue is related to how CSS defines the baseline and line box:

  1. Line box height = line-height (16px in our case)
  2. Inline box = The box around inline content (the <span>)
  3. Baseline = The reference line for aligning inline boxes
  4. Cap-height = Top of capital letters (what Photoshop's "hanging" uses)

With display: inline-block and explicit line-height on spans (from #274):

  • The span's inline box is properly constrained to line-height
  • But the baseline is still centered in the line box
  • Font glyphs extend both above and below the baseline according to font metrics

Potential Solutions

Option 1: Adjust margin-top to account for cap-height

Use font metrics (ascent/descent ratios) to calculate the offset:

# Approximate cap-height ratio for common fonts (Arial: ~0.715)
CAP_HEIGHT_RATIO = 0.715
cap_height_offset = font_size * CAP_HEIGHT_RATIO - leading / 2
margin_top_compensation = -cap_height_offset

Pros:

  • Addresses the root cause
  • Could work across different fonts

Cons:

  • Requires accurate font metrics (varies by font)
  • May need per-font calibration
  • Complex to implement generically

Option 2: Use CSS text-box-trim (experimental)

The CSS text-box-trim property (currently experimental) allows trimming the half-leading space from the first and last lines.

Pros:

  • Native CSS solution
  • Semantically correct

Cons:

  • Limited browser support (experimental)
  • Not available in most SVG renderers

Option 3: Use absolute positioning for first line

Position the first line explicitly using position: relative with top offset.

Pros:

  • Precise control over first-line position

Cons:

  • Breaks normal flow
  • Complex interaction with line wrapping

Option 4: Accept the limitation and document

Document that foreignObject mode has slight first-line baseline differences and recommend native SVG text for precise positioning.

Pros:

  • Simple, honest approach
  • ForeignObject mode still useful for text wrapping

Cons:

  • Doesn't solve the issue
  • May disappoint users expecting pixel-perfect matching

Recommended Approach

I suggest a combination of Option 1 (with empirical calibration) and Option 4 (documentation):

  1. Calibrate the offset empirically for common fonts (Arial, Helvetica, etc.)
  2. Add a configuration option for cap-height-to-font-size ratio (default: 0.715)
  3. Document the limitation in the foreignObject mode documentation
  4. Add tests to track the baseline offset for regression detection

This provides a practical improvement while acknowledging the inherent complexity of matching Photoshop's text rendering in CSS.

Implementation Notes

The fix would modify _get_foreign_object_paragraph_styles() in src/psd2svg/core/text.py:

# Proposed adjustment (pseudo-code)
if paragraph.spans:
    first_span = paragraph.spans[0]
    font_size = first_span.style.font_size
    if leading > font_size:
        # Half-leading compensation
        half_leading = (leading - font_size) / 2
        
        # Cap-height adjustment (font-specific, default for Arial)
        cap_height_ratio = get_cap_height_ratio(font_family)  # ~0.715 for Arial
        cap_height = font_size * cap_height_ratio
        
        # Align cap-height to top instead of baseline
        margin_top_compensation = -(cap_height - leading / 2)

Success Criteria

  • First line cap-height aligns within acceptable tolerance (±2px) with Photoshop
  • No regression in existing text positioning tests
  • Add test case for first-line baseline alignment
  • Document the limitation and configuration options
  • Consider font metrics for accurate calculation

Environment:

  • psd2svg version: main branch (latest)
  • Test file: tests/fixtures/texts/style-leading-2.psd
  • Related code: src/psd2svg/core/text.py:759-802

Labels: enhancement, text-rendering, foreignObject

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions