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"