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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ ym live report.yml
| `_blockquote` | A quotation with attribution |
| `_code` | A non-executable, syntax-highlighted code block |
| `_py` | Execute Python and optionally show the source |
| `_p` | A standalone paragraph with a selectable text style |
| `_loadjson` | Load variables from a JSON file |
| `_pagebreak` | Force a page break |
| `_hrule` | A configurable horizontal rule |
Expand Down
44 changes: 40 additions & 4 deletions docs/reference/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,11 @@ The suffix does not affect how the block is executed. It is simply an optional i
| [`_code`](#block-code) | A non-executable code block. |
| [`_py`](#block-py) | Execute Python and optionally show the syntax-highlighted source. |
| [`_loadjson`](#block-loadjson) | Load variables into the document from a JSON file. |
<<<<<<< HEAD
| [`_p`](#block-p) | A standalone paragraph, with a selectable text style. |
| [`_ul`](#block-ul) | An unordered (bulleted) list. |
| [`_ol`](#block-ol) | An ordered (numbered) list. |
| [`_pagebreak`](#block-pagebreak) | Force a page break. |
=======
| [`_pagebreak`](#block-pagebreak) | Force a page break, optionally switching page template. |
| [`_nextpagetemplate`](#block-nextpagetemplate) | Arm the page template to switch to at the next break. |
>>>>>>> adb6527 (feat: implement multiple named page templates)
| [`_hrule`](#block-hrule) | A customizable horizontal rule. |
| [`_spacer`](#block-spacer) | Insert vertical whitespace. |

Expand Down Expand Up @@ -255,6 +252,45 @@ _loadjson:

---

(block-p)=
## `_p` — Standalone paragraphs

Emit a paragraph on its own, without first introducing a heading key. This is handy
for body text that follows another block — a note after an admonition, a caption-like
line under a figure — where you don't want a heading and a bare list item wouldn't let
you choose the text style.

The quickest form takes the text directly:

```yaml
Report:
- _info: Something worth noting.
- _p: A follow-up paragraph, no heading required.
```

Use the mapping form to pick a **text style** — any family defined in
[`_style.styles`](configuration.md#cfg-style):

```yaml
Report:
- _matplotfig:
fig: $fig
caption: "Figure 1"
- _p:
content: Figures are approximate; see the appendix for exact values.
style: fine-print
```

| Parameter | Required | Default | Meaning |
| --- | --- | --- | --- |
| `content` | ✅ | — | The paragraph text. `text` is accepted as an alias. |
| `style` | | `default` | Name of the text style family to render the paragraph in. |

Paragraph text supports the same inline markdown and `{{variable}}` interpolation as
ordinary body text. An unknown `style` raises an error listing the available styles.

---

(block-ul)=
## `_ul` — Unordered lists

Expand Down
54 changes: 54 additions & 0 deletions src/ymprint/blocks/p_block.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from reportlab.platypus import Paragraph, Spacer
from . import register_block
from ..content_converters import convert_paragraph
from ..exceptions import YMPrintSyntaxException


def _resolve_family(style_name: str, context: dict) -> str:
"""
Returns 'style_name' if it names a known text style family, else raises.

Kept local (rather than importing story_builder._resolve_style) to avoid a
circular import: story_builder imports the block registry from this package.
"""
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 convert_p_block(block_key: str, block_value, context: dict) -> list[Paragraph | Spacer]:
"""
Renders a standalone paragraph, so an author can emit body text after another
block without first introducing a heading key.

'block_value' is either a bare string (the paragraph text, default style) or a
mapping with:
content / text : the paragraph text (required)
style : a named text style family (default 'default')
"""
if isinstance(block_value, str):
text, style_name = block_value, "default"
elif isinstance(block_value, dict):
if "content" in block_value:
text = block_value["content"]
elif "text" in block_value:
text = block_value["text"]
else:
raise YMPrintSyntaxException(
f"The '{block_key}' block requires a 'content' (or 'text') attribute."
)
style_name = block_value.get("style", "default")
else:
raise YMPrintSyntaxException(
f"The '{block_key}' block value must be a string or a mapping, "
f"got {type(block_value).__name__}."
)

family = _resolve_family(style_name, context)
return convert_paragraph(str(text), context, "body", family)


register_block("_p", convert_p_block)
1 change: 1 addition & 0 deletions src/ymprint/report_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .blocks import json_block
from .blocks import matplotfig_block
from .blocks import code_block
from .blocks import p_block

from .config.config_loaders import load_report_config
from .config import ReportStyles, TableStyle, DocConfig
Expand Down
127 changes: 127 additions & 0 deletions tests/test_p_block.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import pathlib

import pytest

from ymprint.config.config_loaders import load_report_config
from ymprint.context_builder import build_context
from ymprint.blocks.p_block import convert_p_block
from ymprint.story_builder import build_story
from ymprint.exceptions import YMPrintSyntaxException

# A style config with the default family plus a smaller named "fine-print" family,
# so tests can tell the two apart by rendered font size.
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(document_vars=None, style=BASE_STYLE):
styles, tbl, doc = load_report_config({"_style": style}, None)
return build_context(
{}, styles, doc, tbl, document_vars or {},
pathlib.Path.cwd(), pathlib.Path.cwd(), None,
)


def paras(story):
"""(text, fontSize) for every rendered Paragraph in a story."""
return [
(f.getPlainText(), f.style.fontSize)
for f in story
if hasattr(f, "getPlainText")
]


# --- value forms -------------------------------------------------------------------

def test_bare_string_renders_default_body_paragraph():
ctx = make_context()
story = convert_p_block("_p", "A quick paragraph.", ctx)
rendered = paras(story)
assert rendered == [("A quick paragraph.", 10)]


def test_mapping_content_attribute():
ctx = make_context()
story = convert_p_block("_p", {"content": "Mapping form."}, ctx)
assert paras(story) == [("Mapping form.", 10)]


def test_text_alias_for_content():
ctx = make_context()
story = convert_p_block("_p", {"text": "Aliased."}, ctx)
assert paras(story) == [("Aliased.", 10)]


def test_style_selects_named_family():
ctx = make_context()
story = convert_p_block(
"_p", {"content": "Small print.", "style": "fine-print"}, ctx
)
# fine-print body renders at size 6, not the default 10
assert paras(story) == [("Small print.", 6)]


# --- errors ------------------------------------------------------------------------

def test_unknown_style_raises():
ctx = make_context()
with pytest.raises(YMPrintSyntaxException):
convert_p_block("_p", {"content": "x", "style": "does-not-exist"}, ctx)


def test_missing_content_raises():
ctx = make_context()
with pytest.raises(YMPrintSyntaxException):
convert_p_block("_p", {"style": "default"}, ctx)


def test_invalid_value_type_raises():
ctx = make_context()
with pytest.raises(YMPrintSyntaxException):
convert_p_block("_p", ["not", "a", "paragraph"], ctx)


# --- content rendering -------------------------------------------------------------

def test_inline_markdown_is_converted():
ctx = make_context()
story = convert_p_block("_p", "Some **bold** text.", ctx)
# convert_inline_markdown turns **bold** into reportlab <b> markup
assert "<b>bold</b>" in story[0].text


def test_jinja_variable_is_interpolated():
ctx = make_context(document_vars={"name": "Ada"})
story = convert_p_block("_p", "Hello {{name}}.", ctx)
assert paras(story) == [("Hello Ada.", 10)]


# --- end to end via build_story ----------------------------------------------------

def test_paragraph_after_a_block_without_heading():
ctx = make_context()
source = {
"Section": [
{"_info": "An admonition."},
{"_p": {"content": "Follow-up paragraph, no heading needed.", "style": "fine-print"}},
]
}
rendered = dict(paras(build_story(source, ctx)))
assert rendered["Follow-up paragraph, no heading needed."] == 6