From 276a5fe40787e0ee7c1bd228a3f36f83264b869d Mon Sep 17 00:00:00 2001 From: Connor Ferster Date: Thu, 13 Aug 2026 21:32:08 +0000 Subject: [PATCH 1/3] feat: prototype scoped, switchable named text styles Add named text styles and a frame-scoped `_textstyle` block to switch between them within report source, without introducing global parse-time state. - `_style.styles` declares named families as sparse overrides of the default body/headings family (inherit-from-body). - `_textstyle: ` switches the active family for the rest of its recursion frame and is inherited by descendant frames, reverting when the frame returns. Switching swaps the whole family (body, bullets, and derived headings). - State is threaded as a `current_style` parameter through build_story and the content converters -- no global mutable cursor. - Config merge now carries user-defined extension keys (named `styles`) that the defaults have no schema for. Includes a design doc (design/scoped-text-styles.md), an example (Examples/Text Styles), and tests covering inheritance, whole-family heading rescale, scoping/revert, and error cases. Scope limits (documented): only paragraphs, headings, and lists honour the active style; other blocks keep default styling. Names only, no index. Co-Authored-By: Claude Opus 4.8 --- Examples/Text Styles/report.yml | 43 ++++++++ design/scoped-text-styles.md | 149 ++++++++++++++++++++++++++ src/ymprint/config/config_loaders.py | 10 ++ src/ymprint/config/docstyles.py | 62 +++++++++-- src/ymprint/content_converters.py | 44 +++++--- src/ymprint/context_builder.py | 7 +- src/ymprint/story_builder.py | 54 ++++++++-- tests/test-data/example_output1.pdf | Bin 57787 -> 57798 bytes tests/test-data/example_output2.pdf | Bin 78385 -> 78399 bytes tests/test-data/example_output3.pdf | Bin 38520 -> 38520 bytes tests/test-data/filled_forms.pdf | Bin 45804 -> 45804 bytes tests/test_scoped_text_styles.py | 153 +++++++++++++++++++++++++++ 12 files changed, 491 insertions(+), 31 deletions(-) create mode 100644 Examples/Text Styles/report.yml create mode 100644 design/scoped-text-styles.md create mode 100644 tests/test_scoped_text_styles.py diff --git a/Examples/Text Styles/report.yml b/Examples/Text Styles/report.yml new file mode 100644 index 0000000..153cd13 --- /dev/null +++ b/Examples/Text Styles/report.yml @@ -0,0 +1,43 @@ +_style: + body: + font: Helvetica + size: 11 + headings: + font: Helvetica + ratio: major third + color: "#dd9922" + styles: # named styles, each inherits from body/headings + fine-print: + body: + size: 7 + color: "#888888" + callout: + body: + size: 14 + color: "#2266cc" + +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..d86478b --- /dev/null +++ b/design/scoped-text-styles.md @@ -0,0 +1,149 @@ +# 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. +- Alignment / underline are *not* part of this prototype; they are independent + per-style attributes that can be added to `TextStyle` / `HeadingStyle` later and + will compose with this mechanism. + +## 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..615907a 100644 --- a/src/ymprint/config/docstyles.py +++ b/src/ymprint/config/docstyles.py @@ -63,7 +63,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 +71,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 @@ -91,7 +108,7 @@ def build(self) -> StyleSheet1: bulletFontSize=self.body.bullets.size, textColor=self.body.rl_color, ) - + # Headings heading_ratio = self.headings.ratio headings = ['h6', 'h5', 'h4', 'h3', 'h2', 'h1'] @@ -111,5 +128,36 @@ def build(self) -> StyleSheet1: spaceAfter=heading_size/4 ) 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/content_converters.py b/src/ymprint/content_converters.py index fedf164..6220074 100644 --- a/src/ymprint/content_converters.py +++ b/src/ymprint/content_converters.py @@ -19,9 +19,26 @@ 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 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] paragraphs = value.split("\n") paras = [] for para in paragraphs: @@ -35,19 +52,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 +73,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 +85,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 +102,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 cc7d26ba0dd3ad73111b3d62a607a5ea843ee1cf..78a8af7e14ff0b5c0ab87239bcfbf1f0347a7222 100644 GIT binary patch delta 1600 zcmZWoXh@|rr=_H?eT|8odL;46mUEFVVU zcX|@lC*$e`LKs8rw|m^h(Uz*5 zV{K<=!O(z*(+a+J+XwK@o#sdbncfwIVe!*>xW&sH{7DNP&_?jV>`9{-o1jU5tq(W4 zm%s<(BH9SDy>yVQIGqg_?BCbS@(w6IAJo^H5H{jngOh%&sT>pO{A+m^G&8NgK23 zxJ)8@ut$z)TK~>}&UaC>DvB4JyzMRWi{?7Cio+Q3SyRySSTUcRbhrfFNrv(utp=g4 z-i^pnrq_k_<=NL#N2Zmw+tucx?pq-~nxx|in;WU$=tX(5#xhqj7HxKlcgNGJx=kLW z1Dpo#-8JH6%W5s2Ok&z)?HIG$J~eLRiw>QrIQuC1I$-z2z`=Cdsj>-)BXbAm^1%K=E3H~! zvqJEG`JzV;Ezt}jQvJ>shkZ-P(Jc@pT0Tl)wX5IG+U52|!rxw&C7>V3ON^)y$!Q2 zJ~OF=anUyhi-TqIf*qScXLqr?b(s^7DT7W za1s$D2#K|XRH{un=1wkcvMI9b+0u_NgS>VUf4-xPbyIhbIB!qL$Es8KI5+p79*~n^;l8sVpRStVY`ZOqEvLHADS6#WVg6W4l z@fMmYHL5>6R=@ey-l?lhXFkJEUgCJf40B(zo};kx7#Q8k`4RE@f>}s1 zF@MKTSzYRFI!aEob3P-dPX*LzO1)}7`55zv1-&0P_QI66`LDGV($>&kC~C%x_5AUfK1p3FD8M!4J|s_-d(9 z!>=zluHEipgZu)UL>u5gfx{7SxUW1S8pR(UGulo3e}YF6zV=*j_^*0I43>`?cM8XQ zqVZ^V3dYSHPoSWQ?r0PWL&1=d6tvqH#*$o77@P-%NW@^!SPaJ914Sg0DOfiwk>c)x hM-pAoq;L`mV4R@RPcZ4UBLL3j^KQV_*2CWu_!qUq;7$Ml delta 1591 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 E-}*e1LI3~& diff --git a/tests/test-data/example_output3.pdf b/tests/test-data/example_output3.pdf index 63f9504f6ac00b4f0324117075d92eb526644111..f0b736fe02932d400d77b2ce80ff9a8f04f63565 100644 GIT binary patch delta 80 zcmWN@%L%|B5CuT64VzG(?gv7^T}&6LDqs~JQl7_TPP13oE4*={m+sXW d`JW*3dDM29B@7c3ckNvpkuZc%lQeto89%F)6 Date: Thu, 13 Aug 2026 21:39:54 +0000 Subject: [PATCH 2/3] feat: add align and underline text-style attributes Add two per-style formatting attributes that compose with named text styles: - `align` (left/center/right/justify) on body, headings, and named styles, mapped to ReportLab's native ParagraphStyle.alignment via convert_alignment(). - `underline` (bool) applied at render time by wrapping text in ..., since ParagraphStyle has no honored underline attribute. Applies to paragraphs and headings; bullets are not wrapped. Both live on a shared FormatMixin so they are inherited by named styles like any other field. Updates the example and design doc; adds tests. Co-Authored-By: Claude Opus 4.8 --- Examples/Text Styles/report.yml | 3 ++ design/scoped-text-styles.md | 32 ++++++++++++-- src/ymprint/config/docstyles.py | 32 ++++++++++---- src/ymprint/config/helpers.py | 27 ++++++++++++ src/ymprint/content_converters.py | 11 +++++ tests/test-data/example_output1.pdf | Bin 57798 -> 57798 bytes tests/test-data/example_output2.pdf | Bin 78399 -> 78399 bytes tests/test-data/example_output3.pdf | Bin 38520 -> 38520 bytes tests/test-data/filled_forms.pdf | Bin 45804 -> 45804 bytes tests/test_scoped_text_styles.py | 62 ++++++++++++++++++++++++++++ 10 files changed, 156 insertions(+), 11 deletions(-) diff --git a/Examples/Text Styles/report.yml b/Examples/Text Styles/report.yml index 153cd13..688d079 100644 --- a/Examples/Text Styles/report.yml +++ b/Examples/Text Styles/report.yml @@ -2,10 +2,12 @@ _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: @@ -15,6 +17,7 @@ _style: body: size: 14 color: "#2266cc" + align: center # this style centers its paragraphs Scoped text styles: - > diff --git a/design/scoped-text-styles.md b/design/scoped-text-styles.md index d86478b..554dee0 100644 --- a/design/scoped-text-styles.md +++ b/design/scoped-text-styles.md @@ -137,9 +137,35 @@ frame-local parameter gets correct scoping for free. it would change every block's signature. - Style **names only** (no index), since named styles have no natural order the way page templates do. -- Alignment / underline are *not* part of this prototype; they are independent - per-style attributes that can be added to `TextStyle` / `HeadingStyle` later and - will compose with this mechanism. +- 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 diff --git a/src/ymprint/config/docstyles.py b/src/ymprint/config/docstyles.py index 615907a..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") @@ -107,6 +121,7 @@ def build_sheet(self) -> StyleSheet1: bulletFontName=self.body.bullets.font, bulletFontSize=self.body.bullets.size, textColor=self.body.rl_color, + alignment=self.body.rl_alignment, ) # Headings @@ -125,7 +140,8 @@ def build_sheet(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) 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 6220074..cd1d64e 100644 --- a/src/ymprint/content_converters.py +++ b/src/ymprint/content_converters.py @@ -36,15 +36,26 @@ def _family_model(context: dict, current_style: str): 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 = _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) diff --git a/tests/test-data/example_output1.pdf b/tests/test-data/example_output1.pdf index 78a8af7e14ff0b5c0ab87239bcfbf1f0347a7222..f90d470a30dcb503e66e37f76447a6410f70bae2 100644 GIT binary patch delta 80 zcmWN>!3}^Q3;@7~W4J`2l+whQv;}u@9>?(G4kYZsCi1u&eH(rI?rDyq+}4KTXu33H gkIGR!7CVnnYYqhVU3;;mAM3p`0?bwME=@lt)Ldygc6fgi#Q2tQgvkPJn?yjf1p6>Vm+&Fpm zMVtg~N(DibvN|MC8>3Q?^hLJLYYhbl$WXL0Q-mzkm^Ig!rj*Qn%-eBp%W6c}qUrlK G4(0_yz#I<% delta 105 zcmdo0gk}E|mWC~ij|^j-jV&F`jh&4g&7F-boScm;ot%xG41l5r&PGla&c=2&u9gNa zZZ6KwrmoIz&X#5tE|!)~E`}D4hEA3yjwa@&cCmJLT*W1cMI{wQscBs1<|amlT&k+B H{%%|VXw4j< diff --git a/tests/test-data/example_output3.pdf b/tests/test-data/example_output3.pdf index f0b736fe02932d400d77b2ce80ff9a8f04f63565..317bdfb80cfff5b464016fa41b452f88aef9824f 100644 GIT binary patch delta 79 zcmWl~!3lsc37J|Dxp*K;CMZ%hsPT|jh>#l85^-U+w$Y$Fn1`v c)w$r(l)q-Q<*RY2gl>6@ATFTYle3p3aJk5o&}A-p4G`4 e(mzIO=RxH%i|EG4?xHHCR&L-5id^iu$Myk~RTUNh diff --git a/tests/test-data/filled_forms.pdf b/tests/test-data/filled_forms.pdf index fc70a1fc8d1c4705537fbe798491e9546be2378f..6b882f2a34a66996f1e327828d1080a453b035fe 100644 GIT binary patch delta 80 zcmWN=!3lsc3;;lI4VOrhHfcbFB-ZtOxQKKUhv=_E=)ujg%&|OI_aJWxl5t|lC@L)* fJ=8~ delta 80 zcmaF!lMy 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" From b1b806434d87ebb9e926e145550418f04acf4617 Mon Sep 17 00:00:00 2001 From: Connor Ferster Date: Thu, 13 Aug 2026 21:43:50 +0000 Subject: [PATCH 3/3] test: add integration test rendering all text-formatting features Add report_example_4.yml showcasing named text styles, _textstyle switching (both the list-item and mapping-key forms), per-style alignment, and heading / body underline, then render it through load_report in test_load_yaml alongside the other example reports. Co-Authored-By: Claude Opus 4.8 --- tests/test-data/example_output1.pdf | Bin 57798 -> 57787 bytes tests/test-data/example_output2.pdf | Bin 78399 -> 78384 bytes tests/test-data/example_output3.pdf | Bin 38520 -> 38520 bytes tests/test-data/example_output4.pdf | Bin 0 -> 4035 bytes tests/test-data/filled_forms.pdf | Bin 45804 -> 45804 bytes tests/test-data/report_example_4.yml | 77 +++++++++++++++++++++++++++ tests/test_report_reader.py | 7 ++- 7 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/test-data/example_output4.pdf create mode 100644 tests/test-data/report_example_4.yml diff --git a/tests/test-data/example_output1.pdf b/tests/test-data/example_output1.pdf index f90d470a30dcb503e66e37f76447a6410f70bae2..0a881b61adf63572d8cecf0e1ecea8cb323b9157 100644 GIT binary patch delta 1591 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 delta 1032 zcmdn+gk}E|mJK@XOr{po^R*b|>(5TT-FM4CpzZxt26;xGiWq``5JHm5mNO zp3_wxu!PqB_B*5*&7r-iJ4C1PkbL#N`^L%Zl@lBabM~jR9#}46ch5LM=8>g=r^*rg z2gNdL7yq3d*0iXNBUARALt{X%JoEp5ZLTe=Wtk%utq@=f*C{jPnCy1jOlY}_wp)Fu zbnc~;N{@vr!+ySzJ+fb+?!J)Cg9-Bl{*{u%czf*o{T17ev`pT<;b?sR>U&`~BjS(U zIW)au@A|ib7T2aN+rB~d{sOMmz8RYg_igytBG96_bp7i(_bp4$-DIAv5LU8Qzawc) z_8-}6-R6A{CS}y8`$x&E_D!Go0cRJUTXo@p zk$!FOcdltyQnfPKR{L^PqBfsX{{P;ziW=A(X%azxAxUrcBepc!axYHEQgW^Rh1*VMug-GSz&7AT&J1bNxq)Y8%%-4ZiHGfXifV`Fr6=7ts+ z>dcKzO{Xt2WYmo{Gcj{AH#N5~adkF!GBhxEHgF}5^tG&goNF>!Wuv9yb|v*Ri*Nh~U|Up delta 80 zcmWl~!3lsc3`SvajV@91+Ymv7G$va(mCz}AaJ(L^hxfhFm(iCuwQ8i9c)I>kHK-W` d^I}wS9vTHDL^s{xMBzX%U)hBUd(O|@(g(4x6_Nk| diff --git a/tests/test-data/example_output4.pdf b/tests/test-data/example_output4.pdf new file mode 100644 index 0000000000000000000000000000000000000000..92ceff969ca8af69957ef2b50c31f47c51ddb16b GIT binary patch literal 4035 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 NiHtxZ?VRa|e*^ij1kL~e literal 0 HcmV?d00001 diff --git a/tests/test-data/filled_forms.pdf b/tests/test-data/filled_forms.pdf index 6b882f2a34a66996f1e327828d1080a453b035fe..193f2fff0e4a0a4edfcfcd5370a88bf55d7e6028 100644 GIT binary patch delta 80 zcmWN_!3}^Q3;@7{HC&>!w3Nh{&~b<_gZmxZ2l9Fr+5i9m delta 80 zcmWN=!3lsc3;;lI4VOrhHfcbFB-ZtOxQKKUhv=_E=)ujg%&|OI_aJWxl5t|lC@L)* fJ=8~ 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")