Renderers API¶
Studiorum's rendering system provides flexible, extensible output generation from D&D content models to various formats including LaTeX, PDF, and custom formats.
Core Rendering Architecture¶
RenderingContext¶
The rendering context carries all necessary information for output generation:
from studiorum.renderers.core.interfaces import RenderingContext
from typing import Any
@dataclass(frozen=True)
class RenderingContext:
"""Immutable rendering context for output generation."""
output_format: str # "latex", "pdf", "markdown", etc.
omnidexer: OmnidexerProtocol # Content lookup service
content_tracker: ContentTracker # Cross-reference tracking
metadata: dict[str, Any] # Additional rendering metadata
def with_metadata(self, **kwargs: Any) -> RenderingContext:
"""Create new context with additional metadata."""
new_metadata = {**self.metadata, **kwargs}
return replace(self, metadata=new_metadata)
def get_metadata[T](self, key: str, default: T = None) -> T:
"""Type-safe metadata access."""
return self.metadata.get(key, default)
DocumentRenderer Interface¶
Base interface for all document renderers:
from abc import ABC, abstractmethod
from typing import Protocol
@runtime_checkable
class DocumentRenderer(Protocol):
"""Protocol for document rendering engines."""
def get_supported_formats(self) -> list[str]:
"""Get list of supported output formats."""
...
def render_document(
self,
content: list[Any],
context: RenderingContext
) -> str:
"""Render complete document from content list."""
...
def render_single_item(
self,
item: Any,
context: RenderingContext
) -> str:
"""Render individual content item."""
...
LaTeX Rendering System¶
LaTeXDocumentRenderer¶
Primary renderer for LaTeX output:
from studiorum.renderers.latex.document import LaTeXDocumentRenderer
class LaTeXDocumentRenderer:
"""Comprehensive LaTeX document renderer."""
def __init__(self):
self.entry_processor = RecursiveEntryProcessor()
self.template_engine = TemplateEngine()
def render_document(
self,
content: list[Any],
context: RenderingContext
) -> str:
"""Render complete LaTeX document."""
# Process all content entries
processed_entries = []
for item in content:
rendered = self.entry_processor.process_entry(item, context)
processed_entries.append(rendered)
# Generate document structure
document_parts = {
"preamble": self._generate_preamble(context),
"title_page": self._generate_title_page(context),
"table_of_contents": self._generate_toc(context),
"main_content": "\n\n".join(processed_entries),
"appendices": self._generate_appendices(context),
"index": self._generate_index(context)
}
# Render with template
return self.template_engine.render_document(document_parts, context)
def render_creature_stat_block(
self,
creature: Creature,
context: RenderingContext
) -> str:
"""Render creature as LaTeX stat block."""
template_data = {
"name": creature.name,
"size_type": f"{creature.size.value} {creature.creature_type.value}",
"alignment": creature.alignment.value if creature.alignment else "unaligned",
"armor_class": self._format_armor_class(creature.armor_class),
"hit_points": self._format_hit_points(creature.hit_points),
"speed": self._format_speeds(creature.speeds),
"ability_scores": creature.ability_scores.dict(),
"saving_throws": self._format_saving_throws(creature),
"skills": self._format_skills(creature.skills),
"senses": self._format_senses(creature.senses),
"languages": self._format_languages(creature.languages),
"challenge_rating": creature.challenge_rating.rating,
"proficiency_bonus": creature.proficiency_bonus,
"special_abilities": [
self._format_special_ability(ability, context)
for ability in creature.special_abilities
],
"actions": [
self._format_action(action, context)
for action in creature.actions
],
"legendary_actions": [
self._format_legendary_action(action, context)
for action in creature.legendary_actions
]
}
return self.template_engine.render_creature_stat_block(template_data, context)
Entry Processing¶
The entry processor handles the recursive structure of D&D content:
from studiorum.renderers.latex.entry_processor import RecursiveEntryProcessor
class RecursiveEntryProcessor:
"""Processes nested D&D content entries with tag resolution."""
def __init__(self):
self.tag_renderer = UnifiedTagRenderer()
def process_entry(
self,
entry: Any,
context: RenderingContext
) -> str:
"""Process any type of entry content."""
if isinstance(entry, str):
return self._process_text_with_tags(entry, context)
elif isinstance(entry, dict):
return self._process_structured_entry(entry, context)
elif isinstance(entry, list):
return self._process_list_entry(entry, context)
else:
# Handle Pydantic models
return self._process_model_entry(entry, context)
def _process_text_with_tags(
self,
text: str,
context: RenderingContext
) -> str:
"""Process text content with embedded tags."""
# Find all tags in the text
tags = self._extract_tags(text)
# Process each tag
processed_text = text
for tag in tags:
rendered_tag = self.tag_renderer.render_tag(tag, context)
processed_text = processed_text.replace(str(tag), rendered_tag)
return processed_text
def _process_structured_entry(
self,
entry: dict[str, Any],
context: RenderingContext
) -> str:
"""Process structured entry (e.g., tables, lists)."""
entry_type = entry.get("type", "text")
if entry_type == "section":
return self._render_section(entry, context)
elif entry_type == "entries":
return self._render_entries_block(entry, context)
elif entry_type == "table":
return self._render_table(entry, context)
elif entry_type == "list":
return self._render_list(entry, context)
elif entry_type == "inset":
return self._render_inset(entry, context)
elif entry_type == "quote":
return self._render_quote(entry, context)
else:
return self._render_generic_entry(entry, context)
def _render_section(
self,
section: dict[str, Any],
context: RenderingContext
) -> str:
"""Render section with title and content."""
name = section.get("name", "")
entries = section.get("entries", [])
# Process section content
content_parts = []
for entry in entries:
processed = self.process_entry(entry, context)
content_parts.append(processed)
# Format as LaTeX section
section_content = "\n\n".join(content_parts)
if name:
return f"\\subsection{{{name}}}\n\n{section_content}"
else:
return section_content
Tag Rendering System¶
The unified tag renderer handles all D&D content tags:
from studiorum.renderers.latex.tag_renderer import UnifiedTagRenderer
class UnifiedTagRenderer:
"""Unified tag rendering system for all content types."""
def __init__(self):
self.handlers = self._register_handlers()
def render_tag(
self,
tag: ParsedTag,
context: RenderingContext
) -> str:
"""Render tag using appropriate handler."""
handler = self.handlers.get(tag.tag_type)
if handler:
return handler.render(tag, context)
else:
# Fallback to generic rendering
return self._render_generic_tag(tag, context)
def _register_handlers(self) -> dict[str, TagHandler]:
"""Register all tag handlers."""
return {
"creature": CreatureTagHandler(),
"spell": SpellTagHandler(),
"item": ItemTagHandler(),
"adventure": AdventureTagHandler(),
"dice": DiceTagHandler(),
"damage": DamageTagHandler(),
"condition": ConditionTagHandler(),
"sense": SenseTagHandler(),
"skill": SkillTagHandler(),
"action": ActionTagHandler(),
"book": BookTagHandler(),
"quickref": QuickrefTagHandler(),
"note": NoteTagHandler(),
}
# Example tag handler
class CreatureTagHandler:
"""Handler for {@creature} tags."""
def render(
self,
tag: ParsedTag,
context: RenderingContext
) -> str:
"""Render creature tag."""
creature_name = tag.name
source = tag.attributes.get("source")
# Look up creature
omnidexer = context.omnidexer
creature = omnidexer.get_creature_by_name(creature_name)
if not creature:
return f"\\textbf{{{creature_name}}} (creature not found)"
# Track for appendix
context.content_tracker.track_creature(creature)
# Format reference based on context
display_name = tag.attributes.get("displayText", creature.name)
if context.get_metadata("include_cr_in_references", False):
return f"\\textbf{{{display_name}}} (CR {creature.challenge_rating.rating})"
else:
return f"\\textbf{{{display_name}}}"
Template System¶
Jinja2 Template Engine¶
Studiorum uses Jinja2 for flexible template rendering:
from studiorum.renderers.core.template_engine import TemplateEngine
class TemplateEngine:
"""Jinja2-based template rendering engine."""
def __init__(self, template_dir: Path | None = None):
self.template_dir = template_dir or self._get_default_template_dir()
self.env = self._create_jinja_environment()
def render_document(
self,
template_data: dict[str, Any],
context: RenderingContext
) -> str:
"""Render complete document using template."""
template_name = self._get_document_template(context.output_format)
template = self.env.get_template(template_name)
return template.render(
**template_data,
context=context,
format=context.output_format
)
def render_creature_stat_block(
self,
creature_data: dict[str, Any],
context: RenderingContext
) -> str:
"""Render creature stat block."""
template = self.env.get_template("creature_stat_block.tex")
return template.render(creature=creature_data, context=context)
def render_spell_block(
self,
spell_data: dict[str, Any],
context: RenderingContext
) -> str:
"""Render spell description block."""
template = self.env.get_template("spell_block.tex")
return template.render(spell=spell_data, context=context)
def _create_jinja_environment(self) -> Environment:
"""Create configured Jinja2 environment."""
loader = FileSystemLoader(self.template_dir)
env = Environment(
loader=loader,
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True
)
# Add custom filters
env.filters.update({
"latex_escape": self._latex_escape_filter,
"format_modifier": self._format_modifier_filter,
"format_dice": self._format_dice_filter,
"pluralize": self._pluralize_filter,
"title_case": str.title,
"oxford_join": self._oxford_join_filter
})
# Add custom functions
env.globals.update({
"format_challenge_rating": self._format_challenge_rating,
"format_speed_list": self._format_speed_list,
"calculate_modifier": self._calculate_modifier
})
return env
def _latex_escape_filter(self, text: str) -> str:
"""Escape LaTeX special characters."""
escape_chars = {
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"^": r"\textasciicircum{}",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"\\": r"\textbackslash{}"
}
for char, replacement in escape_chars.items():
text = text.replace(char, replacement)
return text
Template Structure¶
LaTeX templates follow a consistent structure:
{# creature_stat_block.tex #}
{% set cr_display = creature.challenge_rating %}
{% if creature.challenge_rating == "0" %}
{% set cr_display = "0 (0 XP)" %}
{% endif %}
\begin{dndmonster}[float=!htb]{{{ creature.name|latex_escape }}}
\begin{multicols}{2}
\textit{{{ creature.size_type|latex_escape }}, {{ creature.alignment|latex_escape }}}
\dndmonsterdescription{Armor Class}{{{ creature.armor_class }}}
\dndmonsterdescription{Hit Points}{{{ creature.hit_points }}}
\dndmonsterdescription{Speed}{{{ creature.speed|join(", ")|latex_escape }}}
\begin{dndmonsterstats}
{% for ability, score in creature.ability_scores.items() %}
{{ score }} ({{ calculate_modifier(score)|format_modifier }})
{% if not loop.last %} & {% endif %}
{% endfor %}
\end{dndmonsterstats}
{% if creature.saving_throws %}
\dndmonsterdescription{Saving Throws}{{{ creature.saving_throws|latex_escape }}}
{% endif %}
{% if creature.skills %}
\dndmonsterdescription{Skills}{{{ creature.skills|join(", ")|latex_escape }}}
{% endif %}
\dndmonsterdescription{Challenge Rating}{{{ cr_display|latex_escape }}}
{% for ability in creature.special_abilities %}
\dndmonsteraction[{{ ability.name|latex_escape }}]{{{ ability.description|latex_escape }}}
{% endfor %}
\dndmonstermelee{Actions}
{% for action in creature.actions %}
\dndmonsteraction[{{ action.name|latex_escape }}]{{{ action.description|latex_escape }}}
{% endfor %}
{% if creature.legendary_actions %}
\dndmonstermelee{Legendary Actions}
{% for action in creature.legendary_actions %}
\dndmonsteraction[{{ action.name|latex_escape }}]{{{ action.description|latex_escape }}}
{% endfor %}
{% endif %}
\end{multicols}
\end{dndmonster}
Content Tracking System¶
ContentTracker¶
Tracks referenced content for appendices:
from studiorum.core.references.content_tracker import ContentTracker
class ContentTracker:
"""Tracks referenced content for appendix generation."""
def __init__(self):
self._tracked_creatures: set[str] = set()
self._tracked_spells: set[str] = set()
self._tracked_items: set[str] = set()
self._tracked_books: set[str] = set()
def track_creature(self, creature: Creature) -> None:
"""Track a creature reference."""
self._tracked_creatures.add(creature.name)
def track_spell(self, spell: Spell) -> None:
"""Track a spell reference."""
self._tracked_spells.add(spell.name)
def track_item(self, item: Item) -> None:
"""Track an item reference."""
self._tracked_items.add(item.name)
def get_tracked_content(self) -> dict[str, set[str]]:
"""Get all tracked content."""
return {
"creatures": self._tracked_creatures.copy(),
"spells": self._tracked_spells.copy(),
"items": self._tracked_items.copy(),
"books": self._tracked_books.copy()
}
def export_for_appendix(self) -> dict[str, list[dict[str, Any]]]:
"""Export tracked content for appendix generation."""
# This would resolve names to full objects
return {
"creatures": self._resolve_creatures(),
"spells": self._resolve_spells(),
"items": self._resolve_items()
}
Custom Renderers¶
Creating Custom Renderers¶
Implement the DocumentRenderer protocol:
class MarkdownRenderer:
"""Custom Markdown renderer."""
def get_supported_formats(self) -> list[str]:
return ["markdown", "md"]
def render_document(
self,
content: list[Any],
context: RenderingContext
) -> str:
"""Render document as Markdown."""
parts = []
for item in content:
rendered = self.render_single_item(item, context)
parts.append(rendered)
return "\n\n".join(parts)
def render_single_item(
self,
item: Any,
context: RenderingContext
) -> str:
"""Render individual item as Markdown."""
if isinstance(item, Creature):
return self._render_creature_markdown(item, context)
elif isinstance(item, Spell):
return self._render_spell_markdown(item, context)
elif isinstance(item, dict):
return self._render_dict_entry(item, context)
else:
return str(item)
def _render_creature_markdown(
self,
creature: Creature,
context: RenderingContext
) -> str:
"""Render creature as Markdown stat block."""
lines = [
f"# {creature.name}",
f"*{creature.size.value} {creature.creature_type.value}*",
"",
f"**Armor Class** {creature.armor_class.display_value}",
f"**Hit Points** {creature.hit_points.display_value}",
f"**Speed** {', '.join(speed.display for speed in creature.speeds)}",
"",
"| STR | DEX | CON | INT | WIS | CHA |",
"|-----|-----|-----|-----|-----|-----|",
f"| {creature.ability_scores.strength} ({creature.get_modifier('strength'):+d}) | "
f"{creature.ability_scores.dexterity} ({creature.get_modifier('dexterity'):+d}) | "
f"{creature.ability_scores.constitution} ({creature.get_modifier('constitution'):+d}) | "
f"{creature.ability_scores.intelligence} ({creature.get_modifier('intelligence'):+d}) | "
f"{creature.ability_scores.wisdom} ({creature.get_modifier('wisdom'):+d}) | "
f"{creature.ability_scores.charisma} ({creature.get_modifier('charisma'):+d}) |",
"",
f"**Challenge Rating** {creature.challenge_rating.rating}",
""
]
# Add actions
if creature.actions:
lines.append("## Actions")
lines.append("")
for action in creature.actions:
lines.append(f"**{action.name}.** {action.description}")
lines.append("")
return "\n".join(lines)
# Register custom renderer
from studiorum.renderers.registry import get_renderer_registry
registry = get_renderer_registry()
registry.register_renderer("markdown", MarkdownRenderer())
Format-Specific Handlers¶
Create specialized handlers for different output formats:
class HTMLRenderer:
"""HTML renderer for web output."""
def get_supported_formats(self) -> list[str]:
return ["html", "web"]
def render_document(
self,
content: list[Any],
context: RenderingContext
) -> str:
"""Render as HTML document."""
# Use Jinja2 templates for HTML
template_engine = TemplateEngine(template_dir="templates/html")
document_data = {
"title": context.get_metadata("title", "D&D Content"),
"content_items": [
self.render_single_item(item, context)
for item in content
]
}
return template_engine.render_template("document.html", document_data)
def render_single_item(
self,
item: Any,
context: RenderingContext
) -> str:
"""Render individual item as HTML."""
if isinstance(item, Creature):
return self._render_creature_html(item, context)
# ... other types
def _render_creature_html(
self,
creature: Creature,
context: RenderingContext
) -> str:
"""Render creature as HTML stat block."""
return f"""
<div class="creature-stat-block">
<h2 class="creature-name">{creature.name}</h2>
<p class="creature-type">
<em>{creature.size.value} {creature.creature_type.value}</em>
</p>
<div class="creature-stats">
<div class="stat-line">
<strong>Armor Class</strong> {creature.armor_class.display_value}
</div>
<div class="stat-line">
<strong>Hit Points</strong> {creature.hit_points.display_value}
</div>
<!-- ... more stats ... -->
</div>
<div class="creature-actions">
<h3>Actions</h3>
{self._render_actions_html(creature.actions)}
</div>
</div>
"""
Renderer Configuration¶
Renderer Settings¶
Configure renderer behavior through the context:
# Create context with renderer-specific settings
context = RenderingContext(
output_format="latex",
omnidexer=omnidexer,
content_tracker=content_tracker,
metadata={
# LaTeX-specific settings
"latex_compiler": "xelatex",
"include_toc": True,
"include_index": False,
"paper_size": "letter",
"font_size": "10pt",
# Rendering options
"include_cr_in_references": True,
"show_source_abbreviations": True,
"group_similar_content": True,
# Appendix settings
"appendix_creatures": True,
"appendix_spells": True,
"appendix_items": True,
"appendix_max_items": 50
}
)
# Use configured context
renderer = LaTeXDocumentRenderer()
output = renderer.render_document(content, context)
Template Customization¶
Customize templates for specific campaigns:
# Campaign-specific template directory
campaign_templates = Path("campaigns/curse-of-strahd/templates")
# Create template engine with custom templates
template_engine = TemplateEngine(template_dir=campaign_templates)
# Override specific templates
context = context.with_metadata(
template_overrides={
"creature_stat_block.tex": "gothic_creature_block.tex",
"spell_block.tex": "dark_magic_spell.tex"
}
)
Performance Optimization¶
Caching¶
Implement caching for expensive rendering operations:
from functools import lru_cache
from typing import Hashable
class CachingRenderer:
"""Renderer with built-in caching."""
def __init__(self, base_renderer: DocumentRenderer):
self.base_renderer = base_renderer
@lru_cache(maxsize=1000)
def _render_cached_item(
self,
item_hash: Hashable,
item_data: str,
format_type: str
) -> str:
"""Cache rendered items by hash."""
# This would be called by render_single_item
# with a hash of the item and format
pass
def render_single_item(
self,
item: Any,
context: RenderingContext
) -> str:
"""Render with caching."""
# Generate cache key
item_hash = self._generate_item_hash(item, context.output_format)
item_data = self._serialize_item(item)
return self._render_cached_item(
item_hash,
item_data,
context.output_format
)
Streaming Rendering¶
For large documents, use streaming:
from typing import Generator
class StreamingRenderer:
"""Renderer that yields content in chunks."""
def render_document_stream(
self,
content: list[Any],
context: RenderingContext
) -> Generator[str, None, None]:
"""Yield document content in chunks."""
# Yield document header
yield self._render_document_header(context)
# Yield each content item
for item in content:
rendered_item = self.render_single_item(item, context)
yield rendered_item
yield "\n\n" # Item separator
# Yield document footer
yield self._render_document_footer(context)
Testing Renderers¶
Renderer Testing Patterns¶
import pytest
from unittest.mock import Mock
class TestLaTeXRenderer:
def setup_method(self):
self.renderer = LaTeXDocumentRenderer()
self.mock_omnidexer = Mock(spec=OmnidexerProtocol)
self.content_tracker = ContentTracker()
self.context = RenderingContext(
output_format="latex",
omnidexer=self.mock_omnidexer,
content_tracker=self.content_tracker,
metadata={}
)
def test_creature_stat_block_rendering(self):
"""Test creature stat block generation."""
creature = self._create_test_creature()
result = self.renderer.render_creature_stat_block(creature, self.context)
# Verify LaTeX structure
assert "\\begin{dndmonster}" in result
assert "\\end{dndmonster}" in result
assert creature.name in result
assert str(creature.challenge_rating.rating) in result
def test_content_tracking(self):
"""Test that content is properly tracked."""
creature = self._create_test_creature()
self.renderer.render_creature_stat_block(creature, self.context)
# Verify creature was tracked
tracked = self.content_tracker.get_tracked_content()
assert creature.name in tracked["creatures"]
def test_tag_processing_in_text(self):
"""Test tag processing in adventure text."""
text_with_tags = "The {@creature Hobgoblin|SRD} attacks with a {@item scimitar|SRD}."
# Mock omnidexer responses
mock_hobgoblin = Mock()
mock_hobgoblin.name = "hobgoblin"
self.mock_omnidexer.get_creature_by_name.return_value = mock_hobgoblin
mock_scimitar = Mock()
mock_scimitar.name = "scimitar"
self.mock_omnidexer.get_item_by_name.return_value = mock_scimitar
result = self.renderer.entry_processor._process_text_with_tags(
text_with_tags,
self.context
)
# Verify tags were processed
assert "hobgoblin" in result
assert "scimitar" in result
assert "{@creature" not in result # Tags should be resolved
Next Steps¶
- Services API: Service container and dependency injection
- Models API: Data models and validation
- Architecture Guide: Overall system design
- AI Agents Guide: MCP integration patterns