diff --git a/README.md b/README.md index e44541c..57d5b91 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/reference/blocks.md b/docs/reference/blocks.md index 38f9be0..4b03eaa 100644 --- a/docs/reference/blocks.md +++ b/docs/reference/blocks.md @@ -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. | @@ -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 diff --git a/src/ymprint/blocks/p_block.py b/src/ymprint/blocks/p_block.py new file mode 100644 index 0000000..c256f16 --- /dev/null +++ b/src/ymprint/blocks/p_block.py @@ -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) diff --git a/src/ymprint/report_reader.py b/src/ymprint/report_reader.py index 9a44fc9..5994501 100644 --- a/src/ymprint/report_reader.py +++ b/src/ymprint/report_reader.py @@ -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 diff --git a/tests/test_p_block.py b/tests/test_p_block.py new file mode 100644 index 0000000..66968e4 --- /dev/null +++ b/tests/test_p_block.py @@ -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 markup + assert "bold" 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