diff --git a/Examples/Text Styles/report.yml b/Examples/Text Styles/report.yml
new file mode 100644
index 0000000..688d079
--- /dev/null
+++ b/Examples/Text Styles/report.yml
@@ -0,0 +1,46 @@
+_style:
+ body:
+ font: Helvetica
+ size: 11
+ align: justify # left | center | right | justify
+ headings:
+ font: Helvetica
+ ratio: major third
+ color: "#dd9922"
+ underline: true # underline all headings
+ styles: # named styles, each inherits from body/headings
+ fine-print:
+ body:
+ size: 7
+ color: "#888888"
+ callout:
+ body:
+ size: 14
+ color: "#2266cc"
+ align: center # this style centers its paragraphs
+
+Scoped text styles:
+ - >
+ This opening paragraph uses the default body style. Named text styles let you switch
+ the active family for part of a document.
+
+ - Fine print section:
+ - _textstyle: fine-print
+ - >
+ Everything from here down in this section renders in the fine-print style — smaller
+ and grey. Because switching swaps the whole family, the heading above is scaled to
+ the fine-print size too.
+ - A nested sub-clause:
+ - This inherits fine-print (a child of the switched section).
+ - _textstyle: default
+ - >
+ And now we have switched back to the default style for the remainder of this section.
+
+ - A callout:
+ - _textstyle: callout
+ - >
+ This paragraph uses the callout style — larger and blue.
+
+ - Back to normal:
+ - >
+ This section is default again; the callout scope ended with its list.
diff --git a/design/scoped-text-styles.md b/design/scoped-text-styles.md
new file mode 100644
index 0000000..554dee0
--- /dev/null
+++ b/design/scoped-text-styles.md
@@ -0,0 +1,175 @@
+# Design: Scoped, switchable text styles
+
+Status: prototype
+Branch: `features/scoped-text-styles` (off `main`)
+
+## Motivation
+
+Today `_style` defines exactly one text family: a `body` paragraph style plus a
+`headings` family whose `h1…h6` sizes are derived from `body.size` via a musical
+`ratio`. There is no way to define an alternate text style (e.g. "fine print",
+"callout") and apply it to part of a document.
+
+This feature adds **named text styles** and a way to **switch between them** within
+the report source, while preserving YMPrint's "minimal config, maximal flexibility"
+ethos and — critically — without introducing global mutable state during parsing.
+
+## Why not annotate the heading key
+
+The natural-looking syntax
+
+```yaml
+Legal disclaimer {style: fine-print}: # ← INVALID
+```
+
+is not valid YAML: the `: ` (colon-space) inside the braces is a mapping-value
+indicator and is illegal inside a block-context plain scalar (the parser raises
+`ScannerError: mapping values are not allowed here`). It is only salvageable with a
+colon-free marker (`{style=fine-print}`, `@fine-print`, …), which turns the key into
+a bespoke micro-syntax that YMPrint must regex-parse and strip — exactly the "markup
+soup" the project avoids, plus a collision risk with real heading text.
+
+**Decision:** keep the style annotation as ordinary YAML *data* (a block), not baked
+into the key string.
+
+## Source syntax: the `_textstyle` block
+
+`_textstyle` sets the active text style for everything that follows it **within its
+recursion frame**, and is inherited by descendant frames. It renders no flowable of
+its own.
+
+```yaml
+Legal disclaimer:
+ - _textstyle: fine-print # applies from here down in THIS section
+ - This paragraph is fine-print.
+ - Sub-clause:
+ - Inherited fine-print.
+ - _textstyle: default # switch back
+ - Back to the default style.
+
+Next section:
+ - Body style again (the disclaimer's scope ended with its list).
+```
+
+- Value is a **style name** (string). `default` is always available and refers to
+ the top-level `body`/`headings`.
+- It may appear as a mapping key (`_textstyle: name`) or as a single-key list item
+ (`- _textstyle: name`); both are recognised.
+- An unknown style name raises `YMPrintSyntaxException`.
+
+### Decisions (locked)
+
+1. **Whole family.** Switching swaps the *entire* family: body paragraphs, bullet
+ lists, and the derived `h1…h6` headings all follow the active style. A named
+ style with a smaller `body.size` therefore also shrinks headings within its
+ scope (sizes are `body.size * ratio`).
+2. **Anywhere, flips onward.** `_textstyle` may appear at any position in a section's
+ list. It restyles every following sibling in that frame plus their descendants,
+ and reverts automatically when the frame ends. This mirrors the ergonomics of
+ `_pagebreak` / `_nextpagetemplate`.
+3. **Inherit from body.** A named style specifies only what differs; unspecified
+ fields fall back to the default family (`body` + `headings`). DRY, matching the
+ config system's inherit-from-defaults behaviour.
+
+## Config syntax: named styles under `_style.styles`
+
+```yaml
+_style:
+ body: { font: NotoSans, size: 10, color: black, spacing: 1.7, bullets: {...} }
+ headings: { font: AppleGaramond, ratio: minor third, color: "#dd9922" }
+ styles: # NEW: named, each inherits the default family
+ fine-print:
+ body: { size: 8, color: "#666666" }
+ callout:
+ body: { size: 12 }
+ headings: { ratio: major third }
+```
+
+Each entry under `styles` is a **sparse override** of the whole family. It is
+deep-merged onto the default `{body, headings}` before being validated and built
+into a complete stylesheet. `styles` can be declared at any config priority level
+(document front matter or a project `*.ymprint.yml`).
+
+## State management (the crux)
+
+Unlike page templates — where ReportLab owns the "active template" across a linear
+page stream via `NextPageTemplate` — there is no ReportLab mechanism for an active
+paragraph style. Every `Paragraph` is constructed with an explicit style object.
+
+The switch is therefore managed as a **frame-local parameter**, not global state:
+
+- `build_story(source_data, context, level, current_style="default")` threads
+ `current_style` through the recursion.
+- When a `_textstyle` item is encountered, `build_story` updates its **local**
+ `current_style` variable for the remainder of that loop and passes it into any
+ child `build_story(...)` calls.
+- Because it is a local variable, siblings after the switch see it, descendants
+ inherit it, and when the frame returns the parent's `current_style` is untouched —
+ scope is automatic, with no reset and no cross-branch leakage.
+
+`_textstyle` is intercepted directly in `build_story` (before the block-registry
+dispatch) because it changes parse state rather than producing a flowable. This
+interception handles both the mapping-key form and the single-key list-item form.
+
+### Why frame-local, not a global cursor
+
+A global "active style" cursor (mutated in `context`) would be order-dependent and,
+given the recursive descent, would bleed a switch made inside a nested list back out
+to the parent's later siblings unless every descent snapshotted and restored it. The
+frame-local parameter gets correct scoping for free.
+
+## Implementation surface
+
+| File | Change |
+| --- | --- |
+| `config/docstyles.py` | Split family-building into `StyleFamily` (body + headings → `StyleSheet1`); `ReportStyles` gains `styles: dict[str, dict]` and `build_families()` returning `{name: (StyleFamily, StyleSheet1)}` with sparse overrides deep-merged onto the default. |
+| `context_builder.py` | Store `context['styles']['families'] = {name: {'ymprint': StyleFamily, 'rl': StyleSheet1}}`; keep `['ymprint']` / `['rl']['_style']` pointing at the default family for back-compat. |
+| `story_builder.py` | Thread `current_style`; intercept `_textstyle`; pass style into paragraph / ul / ol conversions. |
+| `content_converters.py` | `convert_paragraph` / `convert_ul` / `convert_ol` take `current_style` and resolve the family from `context['styles']['families']`. |
+| `config_loaders.py` | Carry user-defined extension keys (`styles`) through the config merge instead of dropping them (defaults have no schema for them). |
+| `exceptions.py` | Reuse `YMPrintSyntaxException` for unknown style names. |
+
+## Scope limits of this prototype
+
+- Only **paragraphs, headings, and bullet/numbered lists** honour the active style.
+ Other blocks (admonitions, quote, code, images, tables) keep their existing
+ styling; threading `current_style` into every block converter is deferred, since
+ it would change every block's signature.
+- Style **names only** (no index), since named styles have no natural order the way
+ page templates do.
+- Style **names only** (no index).
+
+## Alignment and underline
+
+Two independent per-style attributes compose with the family mechanism above; both
+are inherited by named styles like any other field.
+
+```yaml
+_style:
+ body:
+ align: justify # left | center | right | justify (default: left)
+ headings:
+ underline: true # default: false
+ styles:
+ callout:
+ body: { align: center }
+```
+
+- `align` maps to ReportLab's native `ParagraphStyle.alignment` in `build_sheet`
+ (`convert_alignment` in `config/helpers.py`; accepts `centre`/`justified`
+ spellings). An unknown value raises `YMPrintValueError`.
+- `underline` is applied at **render time** in `convert_paragraph` by wrapping the
+ rendered text in `…` — ReportLab's `ParagraphStyle` has no honored
+ `underline` attribute, so the inline tag (already used for ``/``) is the
+ version-independent route. It applies to paragraphs and headings; bullets are not
+ wrapped. (A rule-under-heading variant remains a possible future option.)
+
+Both attributes live on the shared `FormatMixin`, so they are available on `body`,
+`headings`, and every named style.
+
+## Open questions for later
+
+- Should other blocks (admonitions, quotes, code captions) inherit the active style?
+- Do we want a one-off inline form (`{style: …}` on a single run) in addition to the
+ scoped block?
+- Should alignment be a per-style attribute (config) or also settable per-scope?
diff --git a/src/ymprint/config/config_loaders.py b/src/ymprint/config/config_loaders.py
index 70ce385..d3bbb86 100644
--- a/src/ymprint/config/config_loaders.py
+++ b/src/ymprint/config/config_loaders.py
@@ -103,6 +103,16 @@ def build_current_config(default_config: dict, config_data: DeepChainMap):
if default_value is not None:
value = build_current_config(default_config[key], value)
style_map.update({key: value})
+
+ # Carry forward user-defined extension keys that the defaults have no schema for
+ # (e.g. named text `styles`). They are taken wholesale from the highest-priority
+ # layer that defines a non-empty value, rather than merged key-by-key.
+ for mapping in config_data.maps:
+ for key, value in mapping.items():
+ if key in style_map or key in default_config:
+ continue
+ if value:
+ style_map[key] = value
return style_map
diff --git a/src/ymprint/config/docstyles.py b/src/ymprint/config/docstyles.py
index 27e20a8..bc69d45 100644
--- a/src/ymprint/config/docstyles.py
+++ b/src/ymprint/config/docstyles.py
@@ -1,13 +1,19 @@
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict, BeforeValidator
-from typing import TypeAlias, Annotated
+from typing import TypeAlias, Annotated, Optional
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.styles import StyleSheet1
-from .helpers import convert_color, YMPrintValueError
+from .helpers import convert_color, convert_alignment, YMPrintValueError
from enum import Enum
+
+class FormatMixin:
+ """Shared paragraph formatting attributes for text and heading styles."""
+ align: Optional[str] = Field(default=None)
+ underline: bool = Field(default=False)
+
class HeadingRatio(float, Enum):
minor_second = 1.067
major_second = 1.125
@@ -32,9 +38,9 @@ def from_ratio_name(cls, ratio_name: str):
)
-class TextStyle(BaseModel):
+class TextStyle(FormatMixin, BaseModel):
model_config = ConfigDict(populate_by_name=True)
-
+
font: str
size: int
color: str
@@ -42,11 +48,15 @@ class TextStyle(BaseModel):
@property
def rl_color(self):
return convert_color(self.color)
-
-class HeadingStyle(BaseModel):
+ @property
+ def rl_alignment(self):
+ return convert_alignment(self.align)
+
+
+class HeadingStyle(FormatMixin, BaseModel):
model_config = ConfigDict(populate_by_name=True)
-
+
font: str
ratio: Annotated[HeadingRatio | float, BeforeValidator(HeadingRatio.from_ratio_name)]
color: str
@@ -55,6 +65,10 @@ class HeadingStyle(BaseModel):
def rl_color(self):
return convert_color(self.color)
+ @property
+ def rl_alignment(self):
+ return convert_alignment(self.align)
+
class SpacingMixin:
spacing: float = Field(alias="spacing")
@@ -63,7 +77,7 @@ class SpacingMixin:
class SymbolMixin:
symbols: str = Field(default="-", alias='symbol')
-class BulletStyle(SpacingMixin, TextStyle):
+class BulletStyle(SymbolMixin, SpacingMixin, TextStyle):
indent_bullet: float = Field(alias='indent-bullet')
indent_text: float = Field(alias='indent-text')
@@ -71,14 +85,31 @@ class BulletStyle(SpacingMixin, TextStyle):
class BodyTextStyle(SpacingMixin, TextStyle):
bullets: BulletStyle
-class ReportStyles(BaseModel):
+
+def _deep_merge(base: dict, override: dict) -> dict:
+ """
+ Returns a new dict: 'override' recursively merged onto 'base'. Nested dicts are
+ merged key-by-key; any other value in 'override' replaces the base value.
+ """
+ result = dict(base)
+ for key, value in override.items():
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
+ result[key] = _deep_merge(result[key], value)
+ else:
+ result[key] = value
+ return result
+
+
+class StyleFamily(BaseModel):
+ """A complete text family: a body paragraph style plus a derived heading family."""
model_config = ConfigDict(populate_by_name=True)
body: BodyTextStyle
headings: HeadingStyle
- def build(self) -> StyleSheet1:
+ def build_sheet(self) -> StyleSheet1:
"""
- Returns a reportlab.lib.style.ParagraphStyle
+ Returns a reportlab StyleSheet1 with a 'body' style and derived 'h1'..'h6'
+ heading styles.
"""
leading = self.body.spacing * self.body.size
# leading = self.body.size * 1.2
@@ -90,8 +121,9 @@ def build(self) -> StyleSheet1:
bulletFontName=self.body.bullets.font,
bulletFontSize=self.body.bullets.size,
textColor=self.body.rl_color,
+ alignment=self.body.rl_alignment,
)
-
+
# Headings
heading_ratio = self.headings.ratio
headings = ['h6', 'h5', 'h4', 'h3', 'h2', 'h1']
@@ -108,8 +140,40 @@ def build(self) -> StyleSheet1:
leading=heading_leading,
textColor=self.headings.rl_color,
spaceBefore=heading_size/4,
- spaceAfter=heading_size/4
+ spaceAfter=heading_size/4,
+ alignment=self.headings.rl_alignment,
)
stylesheet.add(heading_style)
-
- return stylesheet
\ No newline at end of file
+
+ return stylesheet
+
+
+class ReportStyles(StyleFamily):
+ # Named text families. Each value is a *sparse* override of the default family
+ # (this instance's body + headings); unspecified fields are inherited.
+ styles: dict[str, dict] = Field(default_factory=dict)
+
+ @property
+ def default_family(self) -> StyleFamily:
+ return StyleFamily(body=self.body, headings=self.headings)
+
+ def build(self) -> StyleSheet1:
+ """Returns the default family's stylesheet (back-compat)."""
+ return self.default_family.build_sheet()
+
+ def build_families(self) -> dict[str, tuple["StyleFamily", StyleSheet1]]:
+ """
+ Returns a mapping of style name -> (StyleFamily, StyleSheet1). The 'default'
+ family is always present; each named style under 'styles' is deep-merged onto
+ the default family before being validated and built.
+ """
+ default_family = self.default_family
+ families: dict[str, tuple[StyleFamily, StyleSheet1]] = {
+ "default": (default_family, default_family.build_sheet())
+ }
+ base_raw = default_family.model_dump(by_alias=True)
+ for name, override in self.styles.items():
+ merged = _deep_merge(base_raw, override or {})
+ family = StyleFamily.model_validate(merged)
+ families[name] = (family, family.build_sheet())
+ return families
\ No newline at end of file
diff --git a/src/ymprint/config/helpers.py b/src/ymprint/config/helpers.py
index f083ab9..940a16e 100644
--- a/src/ymprint/config/helpers.py
+++ b/src/ymprint/config/helpers.py
@@ -1,5 +1,32 @@
from reportlab.lib import colors
from reportlab.lib import pagesizes as rl_pagesizes
+from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
+
+_ALIGNMENTS = {
+ "left": TA_LEFT,
+ "center": TA_CENTER,
+ "centre": TA_CENTER,
+ "right": TA_RIGHT,
+ "justify": TA_JUSTIFY,
+ "justified": TA_JUSTIFY,
+}
+
+
+def convert_alignment(align_spec: str | None) -> int:
+ """
+ Converts an alignment name (left/center/right/justify) into a ReportLab
+ alignment constant. None or unset defaults to left-aligned.
+ """
+ if align_spec is None:
+ return TA_LEFT
+ key = str(align_spec).strip().lower()
+ if key not in _ALIGNMENTS:
+ raise YMPrintValueError(
+ f"Alignment {align_spec!r} is not recognized. "
+ f"Choose from: {sorted(set(_ALIGNMENTS) - {'centre', 'justified'})}."
+ )
+ return _ALIGNMENTS[key]
+
def convert_color(color_spec: str) -> colors.Color:
"""
diff --git a/src/ymprint/content_converters.py b/src/ymprint/content_converters.py
index fedf164..cd1d64e 100644
--- a/src/ymprint/content_converters.py
+++ b/src/ymprint/content_converters.py
@@ -19,15 +19,43 @@
RLFlowables: TypeAlias = Union[Paragraph, Spacer, Table, KeepTogether, Image]
jinja_env = Environment(undefined=DebugUndefined)
-def convert_paragraph(value: str, context: dict, text_style: str = "body") -> list[Paragraph]:
+def _family_sheet(context: dict, current_style: str):
+ """Returns the reportlab StyleSheet1 for the active text style family."""
+ families = context["styles"].get("families")
+ if families is not None and current_style in families:
+ return families[current_style]["rl"]
+ # Back-compat: fall back to the single default stylesheet.
+ return context["styles"]["rl"]["_style"]
+
+
+def _family_model(context: dict, current_style: str):
+ """Returns the ymprint StyleFamily model for the active text style family."""
+ families = context["styles"].get("families")
+ if families is not None and current_style in families:
+ return families[current_style]["ymprint"]
+ return context["styles"]["ymprint"]
+
+
+def _wants_underline(context: dict, text_style: str, current_style: str) -> bool:
+ """Whether the active family's style for this role requests an underline."""
+ family = _family_model(context, current_style)
+ if text_style.startswith("h"):
+ return bool(getattr(family.headings, "underline", False))
+ return bool(getattr(family.body, "underline", False))
+
+
+def convert_paragraph(value: str, context: dict, text_style: str = "body", current_style: str = "default") -> list[Paragraph]:
"""Returns a Paragraph obj"""
- style = context["styles"]["rl"]['_style'][text_style]
+ style = _family_sheet(context, current_style)[text_style]
+ underline = _wants_underline(context, text_style, current_style)
paragraphs = value.split("\n")
paras = []
for para in paragraphs:
para_md = convert_inline_markdown(para)
template = jinja_env.from_string(para_md)
rendered = template.render(context['vars'])
+ if underline:
+ rendered = f"{rendered}"
rl_para = Paragraph(rendered, style=style)
paras.append(rl_para)
@@ -35,19 +63,18 @@ def convert_paragraph(value: str, context: dict, text_style: str = "body") -> li
return paras
# Test
-def convert_ul(value: list[str], context: dict, level: int = 0) -> list[ListFlowable]:
- text_spacing = context['styles']['ymprint'].body.spacing
- text_size = context['styles']['ymprint'].body.size
+def convert_ul(value: list[str], context: dict, level: int = 0, current_style: str = "default") -> list[ListFlowable]:
+ ymp_style = _family_model(context, current_style)
+ text_spacing = ymp_style.body.spacing
+ text_size = ymp_style.body.size
space_around = text_spacing * text_size / 2
- sheet = context['styles']['rl']['_style']
+ sheet = _family_sheet(context, current_style)
bullet_style: ParagraphStyle = sheet['body']
# bullet_style.spaceAfter = space_around
# bullet_style.spaceBefore = space_around
- bul_context = context['styles']['yaml']['_style']['body']['bullets']
- bul_symbols = bul_context['symbols']
+ bul_symbols = ymp_style.body.bullets.symbols
level_index = level % len(bul_symbols)
bul_symbol = bul_symbols[level_index]
- ymp_style: ReportStyles = context['styles']['ymprint']
bul_color = ymp_style.body.bullets.rl_color
bullet_color_hex = "#{:02x}{:02x}{:02x}".format(
int(bul_color.red),
@@ -57,7 +84,7 @@ def convert_ul(value: list[str], context: dict, level: int = 0) -> list[ListFlow
bullet_contents = []
for elem in value:
if isinstance(elem, list):
- sub_bullets = convert_ul(elem, context, level=level + 1)
+ sub_bullets = convert_ul(elem, context, level=level + 1, current_style=current_style)
bullet_contents.append(sub_bullets)
else:
para_md = convert_inline_markdown(elem)
@@ -69,10 +96,10 @@ def convert_ul(value: list[str], context: dict, level: int = 0) -> list[ListFlow
return [ListFlowable(bullet_contents, start=0, bulletType='bullet', spaceAfter=space_around)]
# Test
-def convert_ol(value: list | dict, context: dict, level: int = 0) -> list[ListFlowable]:
- sheet = context['styles']['rl']['_style']
+def convert_ol(value: list | dict, context: dict, level: int = 0, current_style: str = "default") -> list[ListFlowable]:
+ sheet = _family_sheet(context, current_style)
bullet_style = sheet['body']
- ymp_style: ReportStyles = context['styles']['ymprint']
+ ymp_style = _family_model(context, current_style)
bul_color = ymp_style.body.bullets.rl_color
bullet_color_hex = "#{:02x}{:02x}{:02x}".format(
int(bul_color.red),
@@ -86,7 +113,7 @@ def convert_ol(value: list | dict, context: dict, level: int = 0) -> list[ListFl
number = 1
for elem in items:
if isinstance(elem, (list, dict)):
- sub_bullets = convert_ol(elem, context, level=level + 1)
+ sub_bullets = convert_ol(elem, context, level=level + 1, current_style=current_style)
bullet_contents.append(sub_bullets)
continue
para_md = convert_inline_markdown(elem)
diff --git a/src/ymprint/context_builder.py b/src/ymprint/context_builder.py
index 1c459f7..189965e 100644
--- a/src/ymprint/context_builder.py
+++ b/src/ymprint/context_builder.py
@@ -15,7 +15,8 @@ def build_context(
) -> dict:
# inline_styles = {} if "_style" not in content_yaml else content_yaml.pop("_style")
report_styles = ReportStyles.model_validate(text_styles_yaml['_style'])
- stylesheet = report_styles.build()
+ families = report_styles.build_families()
+ stylesheet = families['default'][1]
# inline_doctemplate = {} if "_doc" not in content_yaml else content_yaml.pop("_doc")
# This is not an appropriate merge. Need the nested chain map.
combined_doctemplate = doctemplate_yaml# | inline_doctemplate
@@ -28,6 +29,10 @@ def build_context(
"styles": {
"yaml": text_styles_yaml,
"ymprint": report_styles,
+ "families": {
+ name: {"ymprint": family, "rl": sheet}
+ for name, (family, sheet) in families.items()
+ },
"rl": {
"_style": stylesheet
},
diff --git a/src/ymprint/story_builder.py b/src/ymprint/story_builder.py
index fc54cbf..32e93cc 100644
--- a/src/ymprint/story_builder.py
+++ b/src/ymprint/story_builder.py
@@ -14,6 +14,8 @@
from .exceptions import YMPrintSyntaxException
from .blocks import get_block_callable, list_blocks, convert_blocks
+TEXTSTYLE_BLOCK = "_textstyle"
+
# Explicit list constructs. These are intercepted structurally in build_story
# (not registered blocks): a bare YAML list is a sequence of content items, while
# bullets/numbers are opt-in via these codes. A trailing `_suffix` is allowed for
@@ -21,6 +23,28 @@
LIST_BLOCK_PATTERN = re.compile(r"^_(ul|ol)(?:_|$)")
+def _extract_textstyle(k, v):
+ """
+ Returns the style name if this element is a `_textstyle` switch, else None.
+ Handles both the mapping-key form (`_textstyle: name`) and the single-key
+ list-item form (`- _textstyle: name`).
+ """
+ if k == TEXTSTYLE_BLOCK:
+ return v
+ if k is None and isinstance(v, dict) and list(v.keys()) == [TEXTSTYLE_BLOCK]:
+ return v[TEXTSTYLE_BLOCK]
+ return None
+
+
+def _resolve_style(style_name, context: dict) -> str:
+ families = context["styles"].get("families", {})
+ if style_name in families:
+ return style_name
+ raise YMPrintSyntaxException(
+ f"Text style {style_name!r} not found. Available styles: {list(families.keys())}"
+ )
+
+
def _extract_list_block(k, v):
"""
Returns (kind, items) when this element is a `_ul`/`_ol` construct, else None.
@@ -40,9 +64,14 @@ def _extract_list_block(k, v):
return kind, value
-def build_story(source_data: dict | list, context: dict, level: int = 0) -> list:
+def build_story(source_data: dict | list, context: dict, level: int = 0, current_style: str = "default") -> list:
"""
- Returns a list of Flowables generated from 'source_data' and 'context'
+ Returns a list of Flowables generated from 'source_data' and 'context'.
+
+ 'current_style' is the active text style family for this frame. It is a plain
+ parameter (not shared state): a `_textstyle` switch updates it for the rest of
+ this frame's siblings and is inherited by descendant frames, then reverts
+ automatically when the frame returns.
"""
story = []
if isinstance(source_data, dict):
@@ -59,15 +88,22 @@ def build_story(source_data: dict | list, context: dict, level: int = 0) -> list
k = None
v = elem
+ # Intercept a text-style switch before any other dispatch. It changes parse
+ # state (the active family) rather than producing a flowable.
+ style_name = _extract_textstyle(k, v)
+ if style_name is not None:
+ current_style = _resolve_style(style_name, context)
+ continue
+
# Explicit unordered / ordered lists are structural, intercepted before any
- # heading or block dispatch.
+ # heading or block dispatch. They honour the active text style.
list_block = _extract_list_block(k, v)
if list_block is not None:
kind, items = list_block
if kind == "ul":
- story.extend(convert_ul(items, context))
+ story.extend(convert_ul(items, context, current_style=current_style))
else:
- story.extend(convert_ol(items, context))
+ story.extend(convert_ol(items, context, current_style=current_style))
continue
if k is not None:
@@ -80,7 +116,7 @@ def build_story(source_data: dict | list, context: dict, level: int = 0) -> list
heading_level = 1
heading_style_name = f"h{heading_level}"
if check_for_paragraph(k, context):
- heading = convert_paragraph(k, context, heading_style_name)
+ heading = convert_paragraph(k, context, heading_style_name, current_style)
story.extend(heading)
if check_for_variable(v, context):
@@ -89,7 +125,7 @@ def build_story(source_data: dict | list, context: dict, level: int = 0) -> list
"To evaluate a string representation of the variable use the {{VAR}} syntax instead."
)
if check_for_paragraph(v, context):
- paragraph = convert_paragraph(v, context)
+ paragraph = convert_paragraph(v, context, "body", current_style)
story.extend(paragraph)
elif check_for_tables(v, context):
table = convert_table(v, context)
@@ -97,8 +133,8 @@ def build_story(source_data: dict | list, context: dict, level: int = 0) -> list
elif isinstance(v, (list, dict)):
# A bare list/mapping is a sequence of content items: strings become
# paragraphs, mappings become subsections. Bullets/numbers require _ul/_ol.
- story.extend(build_story(v, context, level=level + 1))
+ story.extend(build_story(v, context, level=level + 1, current_style=current_style))
continue
else:
continue
- return story
\ No newline at end of file
+ return story
diff --git a/tests/test-data/example_output1.pdf b/tests/test-data/example_output1.pdf
index cc7d26b..0a881b6 100644
Binary files a/tests/test-data/example_output1.pdf and b/tests/test-data/example_output1.pdf differ
diff --git a/tests/test-data/example_output2.pdf b/tests/test-data/example_output2.pdf
index 785f6c6..a04fd08 100644
Binary files a/tests/test-data/example_output2.pdf and b/tests/test-data/example_output2.pdf differ
diff --git a/tests/test-data/example_output3.pdf b/tests/test-data/example_output3.pdf
index 63f9504..8a165e9 100644
Binary files a/tests/test-data/example_output3.pdf and b/tests/test-data/example_output3.pdf differ
diff --git a/tests/test-data/example_output4.pdf b/tests/test-data/example_output4.pdf
new file mode 100644
index 0000000..92ceff9
Binary files /dev/null and b/tests/test-data/example_output4.pdf differ
diff --git a/tests/test-data/filled_forms.pdf b/tests/test-data/filled_forms.pdf
index 48beaa9..193f2ff 100644
Binary files a/tests/test-data/filled_forms.pdf and b/tests/test-data/filled_forms.pdf differ
diff --git a/tests/test-data/report_example_4.yml b/tests/test-data/report_example_4.yml
new file mode 100644
index 0000000..6c49cfc
--- /dev/null
+++ b/tests/test-data/report_example_4.yml
@@ -0,0 +1,77 @@
+_vars:
+ company: Structural Python
+_style:
+ body:
+ font: Helvetica
+ size: 11
+ align: justify
+ headings:
+ font: Helvetica
+ ratio: major third
+ color: "#dd9922"
+ underline: true
+ styles:
+ fine-print:
+ body:
+ size: 7
+ color: "#888888"
+ callout:
+ body:
+ size: 13
+ color: "#2266cc"
+ align: center
+ notice:
+ body:
+ align: right
+ underline: true
+
+Text formatting showcase:
+ - >
+ This opening paragraph uses the default body style: justified text at 11 pt,
+ prepared by {{company}}. The heading above is underlined because the heading
+ style sets underline: true.
+
+ - Alignment and named styles:
+ - >
+ The default body is justified. Named text styles let us switch the active
+ family for part of the document, and each style can carry its own alignment.
+
+ - Fine print (switched inline):
+ - _textstyle: fine-print
+ - >
+ Everything from here down in this section renders in the fine-print style —
+ 7 pt and grey. Because switching swaps the whole family, this section's heading
+ is scaled down to match.
+ - A nested sub-clause:
+ _ul:
+ - This bullet inherits fine-print from its parent scope.
+ - So does this one.
+ - _textstyle: default
+ - >
+ And now the default style is restored for the remainder of this section.
+
+ - Callout (centered):
+ - _textstyle: callout
+ - >
+ This paragraph uses the callout style: larger, blue, and centered.
+
+ - Legal notice (right aligned, underlined body):
+ - _textstyle: notice
+ - >
+ This paragraph is right-aligned and underlined, driven entirely by the
+ 'notice' named style rather than the heading style.
+
+ - Ordered content:
+ - >
+ Back to the default style. Numbered lists render with the active family too:
+ - subitems:
+ _ol:
+ - First numbered item
+ - Second numbered item
+ - Third numbered item
+
+ - Mapping-key switch:
+ _textstyle: fine-print
+ A final note: >
+ This subsection switches style using the mapping-key form of _textstyle
+ (a key inside a mapping, rather than a list item) and renders in fine-print.
diff --git a/tests/test_report_reader.py b/tests/test_report_reader.py
index f884c1a..51e8161 100644
--- a/tests/test_report_reader.py
+++ b/tests/test_report_reader.py
@@ -15,6 +15,10 @@ def report_ex1():
def report_ex2():
return yaml_loader.load_yaml(TEST_DATA / "report_example_2.yml")
+@pytest.fixture
+def report_ex4():
+ return yaml_loader.load_yaml(TEST_DATA / "report_example_4.yml")
+
@pytest.fixture
def default_config():
return load_report_config()
@@ -34,7 +38,8 @@ def default_context(default_config):
)
return context
-def test_load_yaml(report_ex1, report_ex2):
+def test_load_yaml(report_ex1, report_ex2, report_ex4):
load_report(TEST_DATA / "report_example_1.yml", TEST_DATA / "example_output1.pdf", TEST_DATA / "example_1_config")
load_report(TEST_DATA / "report_example_2.yml", TEST_DATA / "example_output2.pdf", TEST_DATA / "example_2_config")
load_report(TEST_DATA / "report_example_3.yml", TEST_DATA / "example_output3.pdf", TEST_DATA / "example_1_config")
+ load_report(TEST_DATA / "report_example_4.yml", TEST_DATA / "example_output4.pdf", TEST_DATA / "example_1_config")
diff --git a/tests/test_scoped_text_styles.py b/tests/test_scoped_text_styles.py
new file mode 100644
index 0000000..b43a765
--- /dev/null
+++ b/tests/test_scoped_text_styles.py
@@ -0,0 +1,215 @@
+import pathlib
+
+import pytest
+
+from ymprint.config.docstyles import ReportStyles, _deep_merge
+from ymprint.config.config_loaders import load_report_config
+from ymprint.context_builder import build_context
+from ymprint.story_builder import build_story, _extract_textstyle, _resolve_style
+from ymprint.exceptions import YMPrintSyntaxException
+
+BASE_STYLE = {
+ "headings": {"font": "Helvetica", "color": "#222222", "ratio": "major third"},
+ "body": {
+ "font": "Helvetica",
+ "color": "black",
+ "size": 10,
+ "spacing": 1.7,
+ "bullets": {
+ "font": "Helvetica",
+ "size": 10,
+ "color": "black",
+ "symbols": "•‣",
+ "spacing": 10,
+ "indent-bullet": 20,
+ "indent-text": 40,
+ },
+ },
+ "styles": {"fine-print": {"body": {"size": 6, "color": "#888888"}}},
+}
+
+
+def make_context(style=BASE_STYLE):
+ styles, tbl, doc = load_report_config({"_style": style}, None)
+ return build_context(
+ {}, styles, doc, tbl, {},
+ pathlib.Path.cwd(), pathlib.Path.cwd(), None,
+ )
+
+
+def para_sizes(story):
+ """(text, fontSize) for every rendered Paragraph in a story."""
+ return [
+ (f.getPlainText(), f.style.fontSize)
+ for f in story
+ if hasattr(f, "getPlainText")
+ ]
+
+
+# --- family building / inheritance -------------------------------------------------
+
+def test_deep_merge_nested():
+ base = {"body": {"size": 10, "color": "black"}, "headings": {"ratio": 1.2}}
+ override = {"body": {"size": 6}}
+ assert _deep_merge(base, override) == {
+ "body": {"size": 6, "color": "black"},
+ "headings": {"ratio": 1.2},
+ }
+
+
+def test_build_families_includes_default_and_named():
+ families = ReportStyles.model_validate(BASE_STYLE).build_families()
+ assert set(families.keys()) == {"default", "fine-print"}
+
+
+def test_named_style_inherits_unspecified_fields():
+ families = ReportStyles.model_validate(BASE_STYLE).build_families()
+ fine_family, fine_sheet = families["fine-print"]
+ # size + color overridden
+ assert fine_sheet["body"].fontSize == 6
+ # font inherited from default body
+ assert fine_sheet["body"].fontName == "Helvetica"
+ # bullet symbols inherited
+ assert fine_family.body.bullets.symbols == "•‣"
+
+
+def test_whole_family_headings_rescale_with_body_size():
+ families = ReportStyles.model_validate(BASE_STYLE).build_families()
+ default_h1 = families["default"][1]["h1"].fontSize
+ fine_h1 = families["fine-print"][1]["h1"].fontSize
+ # fine-print body is smaller, so its derived headings are smaller too
+ assert fine_h1 < default_h1
+ assert fine_h1 == pytest.approx(6 * (1.25 ** 5))
+
+
+# --- config merge ------------------------------------------------------------------
+
+def test_named_styles_survive_config_merge():
+ src = {"_style": {"styles": {"legal": {"body": {"size": 7}}}}}
+ styles, _tbl, _doc = load_report_config(src, None)
+ assert styles["_style"]["styles"] == {"legal": {"body": {"size": 7}}}
+
+
+# --- block detection ---------------------------------------------------------------
+
+def test_extract_textstyle_mapping_key_form():
+ assert _extract_textstyle("_textstyle", "fine-print") == "fine-print"
+
+
+def test_extract_textstyle_list_item_form():
+ assert _extract_textstyle(None, {"_textstyle": "fine-print"}) == "fine-print"
+
+
+def test_extract_textstyle_ignores_other_elements():
+ assert _extract_textstyle("Heading", "text") is None
+ assert _extract_textstyle(None, {"other": 1}) is None
+
+
+def test_resolve_style_unknown_raises():
+ ctx = make_context()
+ with pytest.raises(YMPrintSyntaxException):
+ _resolve_style("does-not-exist", ctx)
+
+
+# --- end-to-end scoping ------------------------------------------------------------
+
+def test_scope_applies_and_reverts():
+ ctx = make_context()
+ source = {
+ "Intro": [
+ "para A",
+ {"_textstyle": "fine-print"},
+ "para B",
+ {"_textstyle": "default"},
+ "para C",
+ ],
+ "Outer": ["outer heading child"], # a heading frame that must be default
+ }
+ sizes = dict(para_sizes(build_story(source, ctx)))
+ assert sizes["para A"] == 10 # default
+ assert sizes["para B"] == 6 # fine-print
+ assert sizes["para C"] == 10 # switched back
+ # Outer heading reverted to default family (h1 = 10 * 1.25**5)
+ assert sizes["Outer"] == pytest.approx(10 * (1.25 ** 5))
+
+
+def test_scope_inherited_into_child_frame():
+ ctx = make_context()
+ source = {
+ "Intro": [
+ {"_textstyle": "fine-print"},
+ {"Nested": ["child para"]},
+ ],
+ }
+ sizes = dict(para_sizes(build_story(source, ctx)))
+ # the nested heading is rendered with the fine-print family (h-level)
+ assert sizes["Nested"] == pytest.approx(6 * (1.25 ** 4))
+
+
+def test_unknown_textstyle_in_source_raises():
+ ctx = make_context()
+ source = {"Intro": [{"_textstyle": "nope"}, "para"]}
+ with pytest.raises(YMPrintSyntaxException):
+ build_story(source, ctx)
+
+
+# --- alignment / underline ---------------------------------------------------------
+
+from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
+from ymprint.config.helpers import convert_alignment, YMPrintValueError
+from ymprint.content_converters import convert_paragraph
+
+ALIGN_STYLE = {
+ "headings": {"font": "Helvetica", "color": "#222", "ratio": "major third",
+ "align": "center", "underline": True},
+ "body": {"font": "Helvetica", "color": "black", "size": 10, "spacing": 1.7,
+ "align": "justify",
+ "bullets": {"font": "Helvetica", "size": 10, "color": "black",
+ "symbols": "•", "spacing": 10, "indent-bullet": 20, "indent-text": 40}},
+ "styles": {"rightaligned": {"body": {"align": "right", "underline": True}}},
+}
+
+
+def test_convert_alignment_names():
+ assert convert_alignment(None) == TA_LEFT
+ assert convert_alignment("center") == TA_CENTER
+ assert convert_alignment("Centre") == TA_CENTER
+ assert convert_alignment("right") == TA_RIGHT
+ assert convert_alignment("justify") == TA_JUSTIFY
+ assert convert_alignment("justified") == TA_JUSTIFY
+
+
+def test_convert_alignment_unknown_raises():
+ with pytest.raises(YMPrintValueError):
+ convert_alignment("diagonal")
+
+
+def test_alignment_applied_to_body_and_headings():
+ families = ReportStyles.model_validate(ALIGN_STYLE).build_families()
+ assert families["default"][1]["body"].alignment == TA_JUSTIFY
+ assert families["default"][1]["h1"].alignment == TA_CENTER
+ assert families["rightaligned"][1]["body"].alignment == TA_RIGHT
+
+
+def test_alignment_defaults_to_left_when_unset():
+ families = ReportStyles.model_validate(BASE_STYLE).build_families()
+ assert families["default"][1]["body"].alignment == TA_LEFT
+
+
+def test_heading_underline_wraps_text():
+ ctx = make_context(ALIGN_STYLE)
+ para = convert_paragraph("My Heading", ctx, "h1", "default")
+ assert para[0].text == "My Heading"
+
+
+def test_body_without_underline_is_not_wrapped():
+ ctx = make_context(ALIGN_STYLE)
+ para = convert_paragraph("Body text", ctx, "body", "default")
+ assert para[0].text == "Body text"
+
+
+def test_underline_follows_active_named_style():
+ ctx = make_context(ALIGN_STYLE)
+ # 'rightaligned' switches on body underline
+ para = convert_paragraph("Small print", ctx, "body", "rightaligned")
+ assert para[0].text == "Small print"