Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ style = Style(
svg = render("# Styled Heading", style=style)
```

### Text Alignment

Control horizontal text alignment using the `text_align` option:

```python
from mdsvg import render, Style

# Center-aligned text (great for titles in dashboards)
style = Style(text_align="center")
svg = render("# Centered Title", style=style)

# Right-aligned text
style = Style(text_align="right")
svg = render("Right aligned content", style=style)

# Default is left-aligned
style = Style(text_align="left")
```

### Built-in Themes

```python
Expand Down Expand Up @@ -263,6 +282,7 @@ renderer = SVGRenderer(use_precise_measurement=False)
| `paragraph_spacing` | 12.0 | Space between paragraphs (px) |
| `list_indent` | 24.0 | List indentation (px) |
| `char_width_ratio` | 0.48 | Average character width ratio |
| `text_align` | "left" | Horizontal text alignment ("left", "center", "right") |

## Playground

Expand Down
5 changes: 5 additions & 0 deletions src/mdsvg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@
GITHUB_THEME,
LIGHT_THEME,
MINIMAL_PRESET,
CodeBlockOverflow,
Style,
StylePresets,
TextAlign,
merge_styles,
)
from .types import (
Expand Down Expand Up @@ -92,6 +94,9 @@
"COMPACT_PRESET",
"MINIMAL_PRESET",
"merge_styles",
# Style option types
"TextAlign",
"CodeBlockOverflow",
# Types
"Size",
"TextMetrics",
Expand Down
14 changes: 12 additions & 2 deletions src/mdsvg/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,15 @@ def _render_text_block(

current_y = ctx.y + font_size # Baseline

# Calculate x position based on text alignment
text_anchor = self.style.get_text_anchor()
if self.style.text_align == "center":
text_x = ctx.x + ctx.width / 2
elif self.style.text_align == "right":
text_x = ctx.x + ctx.width
else: # left (default)
text_x = ctx.x

for line_runs in lines:
if not line_runs:
current_y += line_height
Expand Down Expand Up @@ -945,8 +954,9 @@ def _render_text_block(
# Build the complete text element
text_content = "".join(tspan_parts)
text_element = (
f' <text x="{format_number(ctx.x)}" y="{format_number(current_y)}" '
f'font-size="{format_number(font_size)}" class="{css_class}">'
f' <text x="{format_number(text_x)}" y="{format_number(current_y)}" '
f'font-size="{format_number(font_size)}" class="{css_class}" '
f'text-anchor="{text_anchor}">'
f"{text_content}</text>"
)
elements.append(text_element)
Expand Down
16 changes: 16 additions & 0 deletions src/mdsvg/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
# Code block overflow options
CodeBlockOverflow = Literal["wrap", "show", "hide", "ellipsis"]

# Text alignment options
TextAlign = Literal["left", "center", "right"]


@dataclass(frozen=True)
class Style:
Expand Down Expand Up @@ -58,6 +61,7 @@ class Style:
char_width_ratio: Average character width as ratio of font size.
bold_char_width_ratio: Character width ratio for bold text.
mono_char_width_ratio: Character width ratio for monospace text.
text_align: Horizontal text alignment ("left", "center", "right").
"""

# Fonts
Expand Down Expand Up @@ -119,6 +123,9 @@ class Style:
mono_char_width_ratio: float = 0.6 # Mono char width = font_size × ratio (all chars identical)
text_width_scale: float = 1.1 # Safety margin for browser rendering differences

# Text alignment
text_align: TextAlign = "left" # Horizontal alignment: "left", "center", "right"

def with_updates(self, **kwargs: Any) -> Style:
"""
Create a new Style with updated values.
Expand Down Expand Up @@ -162,6 +169,15 @@ def get_heading_color(self) -> str:
"""Get the color for headings, falling back to text_color."""
return self.heading_color or self.text_color

def get_text_anchor(self) -> str:
"""Get the SVG text-anchor value corresponding to text_align."""
anchor_map = {
"left": "start",
"center": "middle",
"right": "end",
}
return anchor_map.get(self.text_align, "start")


# Pre-built themes
LIGHT_THEME = Style()
Expand Down
39 changes: 39 additions & 0 deletions tests/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,45 @@ def test_very_long_text(self) -> None:
assert svg.count("<text") > 1


class TestTextAlignment:
"""Test text alignment functionality."""

def test_default_alignment_is_left(self) -> None:
"""Test default text alignment is left (start)."""
svg = render("Hello")
assert 'text-anchor="start"' in svg

def test_center_alignment(self) -> None:
"""Test center text alignment."""
style = Style(text_align="center")
svg = render("Hello", style=style)
assert 'text-anchor="middle"' in svg

def test_right_alignment(self) -> None:
"""Test right text alignment."""
style = Style(text_align="right")
svg = render("Hello", style=style)
assert 'text-anchor="end"' in svg

def test_left_alignment_explicit(self) -> None:
"""Test explicit left text alignment."""
style = Style(text_align="left")
svg = render("Hello", style=style)
assert 'text-anchor="start"' in svg

def test_heading_respects_alignment(self) -> None:
"""Test that headings respect text alignment."""
style = Style(text_align="center")
svg = render("# Centered Title", style=style)
assert 'text-anchor="middle"' in svg

def test_style_get_text_anchor_method(self) -> None:
"""Test Style.get_text_anchor() helper method."""
assert Style(text_align="left").get_text_anchor() == "start"
assert Style(text_align="center").get_text_anchor() == "middle"
assert Style(text_align="right").get_text_anchor() == "end"


class TestComplexDocuments:
"""Test complex document rendering."""

Expand Down