diff --git a/Examples/Document Configuration/report.yml b/Examples/Document Configuration/report.yml
index 016bf59..4619307 100644
--- a/Examples/Document Configuration/report.yml
+++ b/Examples/Document Configuration/report.yml
@@ -9,15 +9,17 @@ _style:
Customizing your document configuration and styling:
- >
There are `three` categories of document configuration
- - 1: The document template
- 2: Text styles
- 3: Table styles
-
+ - _ol:
+ - The document template
+ - Text styles
+ - Table styles
+
- >
These three categories are set at three different levels of priority
- - 1: The internal defaults (lowest priority)
- 2: Project config
- 3: Document internal config (highest priority)
+ - _ol:
+ - The internal defaults (lowest priority)
+ - Project config
+ - Document internal config (highest priority)
- >
Each level of priority in the configuration heirarchy effectively inherits the configuration of the level of priority above it.
@@ -27,10 +29,11 @@ Customizing your document configuration and styling:
- The document template (_doc):
- >
The template controls settings such as
- - - Page size
- - Margins
- - PDF background file path
- - And whether or not you have a separate set of the above settings applying to the first page of your document or not.
+ - _ul:
+ - Page size
+ - Margins
+ - PDF background file path
+ - And whether or not you have a separate set of the above settings applying to the first page of your document or not.
- >
Currently, the document template is internally configured to only use a single content frame. This limitation would only affect you if you wanted to do something like a two-column layout (where text flows into the second column on the same page once the first one is full).
@@ -39,39 +42,36 @@ Customizing your document configuration and styling:
- >
With text styles, you can control the body text
- Text styling attributes:
- - Font family
- - Text color
- - Size
- - Line spacing ratio
- - Bullet style:
- - Bullet color
- - Bullet symbol heirarchy
- - Bullet indentation
-
+ _ul:
+ - Font family
+ - Text color
+ - Size
+ - Line spacing ratio
+ - "Bullet style:"
+ - - Bullet color
+ - Bullet symbol heirarchy
+ - Bullet indentation
+
- >
You can also control the text heading styles separately from the body text by adjusting their
-
- - - Font family
- - Text color
- - Typographic ratio (in musical intervals, e.g. "minor second" or "major third")
+
+ - _ul:
+ - Font family
+ - Text color
+ - Typographic ratio (in musical intervals, e.g. "minor second" or "major third")
- Table styles (_tablestyle):
- >
You can control how your tables are displayed by modifying the table style. With the _tablestyle key, you can control
- - Table header
- - background (color)
- - gridlines (above, below, between)
- - text
- - font
- - size
- - color
- - Table rows
- - even (rows, background color)
- - odd (rows, background color)
- - gridlines (above, below, between)
- - text
- - font
- - size
- - color
+ - _ul:
+ - "Table header:"
+ - - background (color)
+ - gridlines (above, below, between)
+ - "text: font, size, color"
+ - "Table rows:"
+ - - even (rows, background color)
+ - odd (rows, background color)
+ - gridlines (above, below, between)
+ - "text: font, size, color"
diff --git a/Examples/Document variables/report.yml b/Examples/Document variables/report.yml
index 7e02423..90a2d1a 100644
--- a/Examples/Document variables/report.yml
+++ b/Examples/Document variables/report.yml
@@ -15,14 +15,13 @@ Document Variables (_vars):
representation is shown (i.e. the result of str(var)).
This document has five variables defined, a, b, c, d, e.
- - _spacer: 0 # The use of a "0-spacer" is a hack to prevent the paragraph above being interpreted as a bullet point.
-
- - - a = {{a}}
- - b = {{b}}
- - c = {{c}}
- - d = {{d}}
- - e = {{e.key2}}
- - f = {{f}}
+ - _ul:
+ - a = {{a}}
+ - b = {{b}}
+ - c = {{c}}
+ - d = {{d}}
+ - e = {{e.key2}}
+ - f = {{f}}
- Passing Python Objects: >
It is also possible to pass variable data as Python objects to blocks (see "YMPrint blocks") using the $var syntax (similar to bash).
diff --git a/Examples/PDF Backgrounds/report.yml b/Examples/PDF Backgrounds/report.yml
index e1293e6..dd96297 100644
--- a/Examples/PDF Backgrounds/report.yml
+++ b/Examples/PDF Backgrounds/report.yml
@@ -26,6 +26,7 @@ Custom styling with PDF backgrounds:
Form fields are _automatically_ populated when a document variable name exactly matches the PDF form field name.
If you want to avoid this behaviour:
- - Make sure that none of your variable names match your field names (i.e. using a special naming convention for your form field names to prevent accidental clashes)
- - Do not use form fields
+ _ul:
+ - Make sure that none of your variable names match your field names (i.e. using a special naming convention for your form field names to prevent accidental clashes)
+ - Do not use form fields
Example: The form fields below have been populated from the document vars
\ No newline at end of file
diff --git a/Examples/Simple example/report.yml b/Examples/Simple example/report.yml
index 06ba833..d395398 100644
--- a/Examples/Simple example/report.yml
+++ b/Examples/Simple example/report.yml
@@ -17,19 +17,20 @@ Report title:
- Standard content types:
- Bullets:
- - Bullet 1
- - Bullet 2
- - Bullet 3
- - - Bullet 4
- - Bullet 5
- - Bullet 6
+ _ul:
+ - Bullet 1
+ - Bullet 2
+ - Bullet 3
+ - - Bullet 4
+ - Bullet 5
+ - Bullet 6
- Ordered list:
- 1: First Item
- 2: Second item
- 3:
- 12: Fourth item,
- 6: Fifth item,
- 10: Sixth Item
+ _ol:
+ - First item
+ - Second item
+ - - Nested first
+ - Nested second
+ - Nested third
- Tables:
- Item Number: 12.01
Description: There is a problem here. This report documents it.
diff --git a/Examples/YMPrint blocks/report.yml b/Examples/YMPrint blocks/report.yml
index c4d4fcf..e1f3382 100644
--- a/Examples/YMPrint blocks/report.yml
+++ b/Examples/YMPrint blocks/report.yml
@@ -16,12 +16,12 @@ Using Blocks:
scale_ratio: 0.3
- Admonitions:
- "The following admonitions blocks are available:"
- -
- - _info
- - _warning
- - _danger
- - _tip
- - _note
+ - _ul:
+ - _info
+ - _warning
+ - _danger
+ - _tip
+ - _note
- They are rendered below
_info: Here is an "info" admonition
@@ -108,10 +108,10 @@ Using Blocks:
for k in [a, b, c]:
acc.append(k)
- "Now, the values of the variables a, b, and c can be included in the document:"
- -
- - a = {{py1.a}} (using py1.a)
- - b = {{py1.b}} (using py1.b)
- - c = {{py1.c}} (using py1.c)
+ - _ul:
+ - a = {{py1.a}} (using py1.a)
+ - b = {{py1.b}} (using py1.b)
+ - c = {{py1.c}} (using py1.c)
- _code:
source: |
yaml_data: is being shown
@@ -132,6 +132,6 @@ Using Blocks:
path: extra_vars.json
namespace: extra_vars
- "Here are the values of the vars contained within the 'extravars' namespace:"
- -
- - bn = {{extra_vars.bn}}
- - dx = {{extra_vars.dx}}
+ - _ul:
+ - bn = {{extra_vars.bn}}
+ - dx = {{extra_vars.dx}}
diff --git a/design/explicit-list-syntax.md b/design/explicit-list-syntax.md
new file mode 100644
index 0000000..40adf83
--- /dev/null
+++ b/design/explicit-list-syntax.md
@@ -0,0 +1,100 @@
+# Design: Explicit list syntax (`_ul` / `_ol`)
+
+Status: prototype
+Branch: `features/explicit-list-syntax` (off `main`)
+
+## Problem
+
+A YAML list under a heading is structurally identical whether the author means
+"several paragraphs" or "an unordered list". Today YMPrint guesses from the list's
+*contents*:
+
+- a **pure** list of strings → `convert_ul` → bullets
+- a **mixed** list (strings + block/subsection dicts) → each string becomes a
+ paragraph
+
+So the same `heading:\n - >prose` renders as a bullet or a paragraph depending on
+what else is in the list. A single wrapped paragraph under a heading silently comes
+out as a one-item bullet list (observed under "Alignment and named styles" in the
+text-styles example).
+
+The ambiguity is **structural** — YAML gives identical structure to both intents —
+so no heuristic can resolve it. One of the two meanings must be made explicit.
+
+## Decision
+
+Make **bullets explicit** and let a bare list mean "a sequence of content items".
+
+- A YAML list is always a *sequence of blocks*: strings become paragraphs, mappings
+ become subsections (heading + content), in order. It is never auto-bulleted.
+- Unordered lists are written with the **`_ul`** block; ordered lists with **`_ol`**.
+- The implicit detections (`check_for_nested_lists` → bullets, and the
+ dict-with-integer-keys → numbered list) are retired from dispatch. The check
+ functions remain (still unit-tested) but no longer drive `build_story`.
+
+This yields a single rule with no guessing: *a list is content in order; bullets and
+numbers are named constructs.*
+
+### Rejected alternative
+
+Making **paragraphs** explicit (`_p`) instead would avoid breaking existing bullet
+lists, but it keeps the surprising default (bare list = bullets) and only adds an
+escape hatch beside the ambiguity rather than removing it.
+
+## Syntax
+
+```yaml
+Findings:
+ - The inspection covered three areas. # paragraph
+ - _ul: # unordered list
+ - The handrail is loose on the north stair.
+ - Two ceiling tiles are water-stained.
+ - - a nested sub-point # nested list → sub-bullets
+ - another sub-point
+ - Recommended actions: # subsection heading
+ _ol: # ordered list
+ - Re-secure the handrail.
+ - Replace the stained tiles.
+```
+
+- `_ul` value is a list; nested lists produce sub-bullets (unchanged `convert_ul`).
+- `_ol` value is a list; numbering is automatic by position; nested lists produce
+ nested numbering. (`convert_ol` also still accepts a mapping for back-compat.)
+- Both may be written as a list item (`- _ul: [...]`) or as the value of a heading
+ key (`heading:\n _ul: [...]`). Suffixes are allowed for uniqueness in a mapping
+ (`_ul_left`, `_ol_steps`), consistent with other block codes.
+
+## Implementation
+
+`_ul` / `_ol` are intercepted **directly in `build_story`**, not registered in the
+block registry. Two reasons:
+
+1. They are structural (they change how a list is interpreted), sitting naturally
+ beside the list-dispatch logic.
+2. It keeps them **forward-compatible with scoped text styles**: `build_story` is
+ where a `current_style` parameter lives (on the `features/scoped-text-styles`
+ branch), so intercepting here lets `_ul`/`_ol` pass the active style into
+ `convert_ul`/`convert_ol`. Routing them through the generic block registry —
+ whose converters do not receive the active style — would make bullets ignore the
+ surrounding `_textstyle` scope. (On this `main`-based branch there is no
+ `current_style` yet; the interception point is chosen so the two features compose
+ cleanly when merged.)
+
+| File | Change |
+| --- | --- |
+| `story_builder.py` | Intercept `_ul`/`_ol` (list-item and heading-value forms, with suffixes) → `convert_ul`/`convert_ol`. Replace the implicit bullet/ordered dispatch: a bare list/mapping now always recurses (strings → paragraphs, mappings → subsections). |
+| `content_converters.py` | `convert_ol` accepts a **list** (positional numbering; nested lists nest) as well as a mapping (back-compat). |
+| test data / examples | Migrate bare-list bullets and integer-keyed ordered lists to `_ul` / `_ol`. Genuine multi-paragraph lists (e.g. report 2 "third topic") are left as lists and now render as paragraphs — the intended fix. |
+
+## Backward compatibility
+
+This is a **breaking** content change (consistent with the pre-1.0 status and the
+earlier multi-page-template change): existing documents that relied on bare lists for
+bullets, or integer-keyed mappings for numbered lists, must adopt `_ul` / `_ol`.
+
+## Open questions
+
+- Should `_ol` support an explicit `start:` offset or custom markers (a/i/…)?
+- Should list items be allowed to contain blocks (e.g. an image inside a bullet)?
+- When merged with scoped text styles, thread `current_style` into the `_ul`/`_ol`
+ interception so bullets honour the active family.
diff --git a/docs/guide/document-structure.md b/docs/guide/document-structure.md
index b9e5b3a..ccb7b7a 100644
--- a/docs/guide/document-structure.md
+++ b/docs/guide/document-structure.md
@@ -16,8 +16,10 @@ Report title:
```
Because a YAML key can only have **one** value, anything with more than a single piece of
-content underneath a heading must be written as a **list**. Each list item is rendered in
-order.
+content underneath a heading must be written as a **list**. A list is a **sequence of
+content items rendered in order**: a string becomes a paragraph, a mapping becomes a
+sub-heading with its own content. A list is *not* a bulleted list — bullets and numbers are
+opt-in via the [`_ul` and `_ol`](#bullet-lists) blocks.
### Heading → paragraph → sub-heading
@@ -65,49 +67,55 @@ Inline markdown is supported inside text — see [Inline formatting](#inline-for
below.
:::
-## Bullet lists
+(bullet-lists)=
+## Bullet lists — `_ul`
-A plain YAML list (items that are *not* mappings) renders as a bulleted list:
+A bare list is a sequence of paragraphs/sub-sections (see above), **not** a bulleted list.
+To render bullets, wrap the items in a `_ul` block:
```yaml
Standard content types:
- - Bullets:
+ _ul:
- Bullet 1
- Bullet 2
- Bullet 3
```
+:::{note}
+This is a change from earlier versions, where a plain list of strings was auto-bulleted.
+A list on its own now means "these items in order" (paragraphs and sub-sections); bullets
+are explicit. This removes the ambiguity where a single wrapped paragraph under a heading
+would come out as a one-item bullet.
+:::
+
### Nested bullets
-Nest a list inside a list item to indent bullets. The bullet symbol changes with depth
+Nest a list inside a `_ul` item to indent bullets. The bullet symbol changes with depth
(the default hierarchy is `•‣⁃∘`):
```yaml
-Bullets:
+_ul:
- Bullet 1
- Bullet 2
- - - Bullet 4
- - Bullet 5
- - Bullet 6
+ - - Sub-bullet A
+ - Sub-bullet B
+ - Sub-bullet C
```
-## Ordered lists
+## Ordered lists — `_ol`
-Write a mapping whose keys act as the visible numbers. The keys are used as labels, so you
-control the numbering:
+Wrap the items in an `_ol` block. Numbering is automatic by position, and nesting a list
+inside an item creates a nested numbered list:
```yaml
Ordered list:
- 1: First Item
- 2: Second item
- 3:
- 12: Fourth item
- 6: Fifth item
- 10: Sixth Item
+ _ol:
+ - First item
+ - Second item
+ - - Nested first
+ - Nested second
```
-Nesting a mapping under an item creates a nested ordered list.
-
## Tables
A list of mappings becomes a **table**. The keys of the first mapping are the column
@@ -179,11 +187,13 @@ Report title:
A paragraph and a sub-heading under one top-level heading require a list.
- Standard content types:
- Bullets:
- - Bullet 1
- - Bullet 2
+ _ul:
+ - Bullet 1
+ - Bullet 2
- Ordered list:
- 1: First Item
- 2: Second item
+ _ol:
+ - First item
+ - Second item
- Tables:
- Item Number: 12.01
Location: Under the stairs
diff --git a/docs/reference/blocks.md b/docs/reference/blocks.md
index 6c67f44..94eceaf 100644
--- a/docs/reference/blocks.md
+++ b/docs/reference/blocks.md
@@ -38,6 +38,8 @@ 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. |
+| [`_ul`](#block-ul) | An unordered (bulleted) list. |
+| [`_ol`](#block-ol) | An ordered (numbered) list. |
| [`_pagebreak`](#block-pagebreak) | Force a page break. |
| [`_hrule`](#block-hrule) | A customizable horizontal rule. |
| [`_spacer`](#block-spacer) | Insert vertical whitespace. |
@@ -248,6 +250,45 @@ _loadjson:
---
+(block-ul)=
+## `_ul` — Unordered lists
+
+A bulleted list. A bare YAML list is a sequence of paragraphs/sub-sections, so bullets are
+written explicitly with `_ul`. Nest a list inside an item to indent (the bullet glyph
+changes with depth, default hierarchy `•‣⁃∘`).
+
+```yaml
+Findings:
+ _ul:
+ - The handrail is loose on the north stair.
+ - Two ceiling tiles are water-stained in the lobby.
+ - - a nested sub-point
+ - another sub-point
+```
+
+`_ul` works as the value of a heading key (as above) or as a list item
+(`- _ul: [...]`). Bullet glyph, colour, and indentation come from `_style.body.bullets`
+(see [Configuration → Text styles](#cfg-style)).
+
+---
+
+(block-ol)=
+## `_ol` — Ordered lists
+
+A numbered list. Numbering is automatic by position; nesting a list inside an item creates
+a nested numbered list.
+
+```yaml
+Recommended actions:
+ _ol:
+ - Re-secure the handrail.
+ - Replace the stained ceiling tiles.
+ - - Nested step one
+ - Nested step two
+```
+
+---
+
(block-pagebreak)=
## `_pagebreak` — Page breaks
diff --git a/src/ymprint/content_converters.py b/src/ymprint/content_converters.py
index 586ddcb..fedf164 100644
--- a/src/ymprint/content_converters.py
+++ b/src/ymprint/content_converters.py
@@ -69,7 +69,7 @@ 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: dict, context: dict, level: int = 0) -> list[ListFlowable]:
+def convert_ol(value: list | dict, context: dict, level: int = 0) -> list[ListFlowable]:
sheet = context['styles']['rl']['_style']
bullet_style = sheet['body']
ymp_style: ReportStyles = context['styles']['ymprint']
@@ -79,17 +79,22 @@ def convert_ol(value: dict, context: dict, level: int = 0) -> list[ListFlowable]
int(bul_color.green),
int(bul_color.blue),
)
+ # Accept a list (positional numbering) or a mapping (back-compat: numbered by
+ # insertion order, keys ignored).
+ items = list(value.values()) if isinstance(value, dict) else value
bullet_contents = []
- for idx, elem in enumerate(value.values(), start=1):
- if isinstance(elem, dict):
+ number = 1
+ for elem in items:
+ if isinstance(elem, (list, dict)):
sub_bullets = convert_ol(elem, context, level=level + 1)
bullet_contents.append(sub_bullets)
- else:
- para_md = convert_inline_markdown(elem)
- template = jinja_env.from_string(para_md)
- rendered = template.render(context['vars'])
- bullet_content = Paragraph(f'{idx}. {rendered}', bullet_style)
+ continue
+ para_md = convert_inline_markdown(elem)
+ template = jinja_env.from_string(para_md)
+ rendered = template.render(context['vars'])
+ bullet_content = Paragraph(f'{number}. {rendered}', bullet_style)
bullet_contents.append(bullet_content)
+ number += 1
return [ListFlowable(bullet_contents, start=0, bulletType='bullet')]
# Test
diff --git a/src/ymprint/story_builder.py b/src/ymprint/story_builder.py
index 676bf85..fc54cbf 100644
--- a/src/ymprint/story_builder.py
+++ b/src/ymprint/story_builder.py
@@ -1,9 +1,8 @@
+import re
+
from .content_checks import (
check_for_paragraph,
- check_for_ordered_nested_lists,
- check_for_subelements,
check_for_tables,
- check_for_nested_lists,
check_for_variable,
)
from .content_converters import (
@@ -15,6 +14,32 @@
from .exceptions import YMPrintSyntaxException
from .blocks import get_block_callable, list_blocks, convert_blocks
+# 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
+# uniqueness within a mapping, consistent with other block codes.
+LIST_BLOCK_PATTERN = re.compile(r"^_(ul|ol)(?:_|$)")
+
+
+def _extract_list_block(k, v):
+ """
+ Returns (kind, items) when this element is a `_ul`/`_ol` construct, else None.
+ Handles both the heading-value form (`_ul: [...]`) and the single-key list-item
+ form (`- _ul: [...]`).
+ """
+ key = value = None
+ if isinstance(k, str) and LIST_BLOCK_PATTERN.match(k):
+ key, value = k, v
+ elif k is None and isinstance(v, dict) and len(v) == 1:
+ (only_key, only_value), = v.items()
+ if isinstance(only_key, str) and LIST_BLOCK_PATTERN.match(only_key):
+ key, value = only_key, only_value
+ if key is None:
+ return None
+ kind = LIST_BLOCK_PATTERN.match(key).group(1)
+ return kind, value
+
+
def build_story(source_data: dict | list, context: dict, level: int = 0) -> list:
"""
Returns a list of Flowables generated from 'source_data' and 'context'
@@ -34,6 +59,17 @@ def build_story(source_data: dict | list, context: dict, level: int = 0) -> list
k = None
v = elem
+ # Explicit unordered / ordered lists are structural, intercepted before any
+ # heading or block dispatch.
+ 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))
+ else:
+ story.extend(convert_ol(items, context))
+ continue
+
if k is not None:
heading_level = level
if str(k).startswith(tuple(registered_blocks)):
@@ -53,19 +89,15 @@ 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)
story.extend(paragraph)
- elif check_for_nested_lists(v, context):
- ul = convert_ul(v,context)
- story.extend(ul)
- elif check_for_ordered_nested_lists(v, context):
- ol = convert_ol(v,context)
- story.extend(ol)
elif check_for_tables(v, context):
- table = convert_table(v,context)
+ table = convert_table(v, context)
story.extend(table)
- elif check_for_subelements(v, context):
- story.extend(build_story(v, context, level = level + 1))
+ 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))
continue
else:
continue
diff --git a/tests/test-data/example_output1.pdf b/tests/test-data/example_output1.pdf
index 94418d2..cc7d26b 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 0af00b7..785f6c6 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 6aeaefc..63f9504 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/filled_forms.pdf b/tests/test-data/filled_forms.pdf
index fe5d2eb..48beaa9 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_1.yml b/tests/test-data/report_example_1.yml
index 033a16a..c046427 100644
--- a/tests/test-data/report_example_1.yml
+++ b/tests/test-data/report_example_1.yml
@@ -36,29 +36,31 @@ title:
scale_ratio: 0.5
caption: Figure 1:Here is my figure
second topic:
- - first bullet point. Result = {{c}}
- - Here is a new bullet a = {{a}}
- - third bullet point From the vars = {{d}}
- -
- - subbullet 1
- - subbullet 2
+ _ul:
+ - first bullet point. Result = {{c}}
+ - Here is a new bullet a = {{a}}
+ - third bullet point From the vars = {{d}}
-
- - subbullet 3
- -
- - subbuullet 4
- - fourth bullet point
+ - subbullet 1
+ - subbullet 2
+ -
+ - subbullet 3
+ -
+ - subbuullet 4
+ - fourth bullet point
third topic:
- First sub paragraph leading into subheading
- Third paragraph here.
- subheading:
- 1: first numbered item
- 2: second numbered item
- 4: third numbered item
- 5:
- 1: Sub item
- 2: sub item
+ _ol:
+ - first numbered item
+ - second numbered item
+ - third numbered item
+ -
+ - Sub item
+ - sub item
An actual topic:
first sub topic: >
Here is another paragraph.
diff --git a/tests/test-data/report_example_2.yml b/tests/test-data/report_example_2.yml
index 57af91a..b69938b 100644
--- a/tests/test-data/report_example_2.yml
+++ b/tests/test-data/report_example_2.yml
@@ -24,18 +24,20 @@ title:
There is a line break here too.
- second topic:
- - first bullet point
- - second bullet point
- - third bullet point
+ _ul:
+ - first bullet point
+ - second bullet point
+ - third bullet point
- _pagebreak: null
- third topic:
- First sub paragraph leading into subheading
- Second sub paragraph leading into subheading
- subheading:
- 1: first numbered item
- 2: second numbered item
- 4: third numbered item
- 3: fourth numbered item
+ _ol:
+ - first numbered item
+ - second numbered item
+ - third numbered item
+ - fourth numbered item
fourth topic:
first sub topic: >
Here is another paragraph.
diff --git a/tests/test_content_converters.py b/tests/test_content_converters.py
index 85d5339..8b86078 100644
--- a/tests/test_content_converters.py
+++ b/tests/test_content_converters.py
@@ -35,13 +35,17 @@ def test_convert_paragraph(report_ex1, default_context):
assert con.convert_paragraph(data['title']['first topic'], default_context)
assert con.convert_paragraph(data['title']['An actual topic']['first sub topic'], default_context)
-def test_convert_ul(report_ex1, default_context):
- data = report_ex1
- assert con.convert_ul(data['title']['second topic'], default_context)
+def test_convert_ul(default_context):
+ items = ["first bullet", "second bullet", ["nested a", "nested b"]]
+ assert con.convert_ul(items, default_context)
-def test_convert_ol(report_ex1, default_context):
- data = report_ex1
- assert con.convert_ol(data['title']['third topic'][2]['subheading'], default_context)
+def test_convert_ol_list(default_context):
+ items = ["first", "second", ["nested one", "nested two"]]
+ assert con.convert_ol(items, default_context)
+
+def test_convert_ol_mapping_backcompat(default_context):
+ items = {1: "first", 2: "second", 3: "third"}
+ assert con.convert_ol(items, default_context)
def test_convert_table(report_ex1, default_context):
data = report_ex1
diff --git a/tests/test_explicit_lists.py b/tests/test_explicit_lists.py
new file mode 100644
index 0000000..3615e2f
--- /dev/null
+++ b/tests/test_explicit_lists.py
@@ -0,0 +1,93 @@
+import pathlib
+
+import pytest
+from reportlab.platypus import Paragraph, ListFlowable
+
+from ymprint.story_builder import build_story, _extract_list_block
+from ymprint.context_builder import build_context
+from ymprint.config.config_loaders import load_report_config
+
+TEST_DATA = pathlib.Path(__file__).parent / "test-data"
+
+
+@pytest.fixture
+def ctx():
+ styles, tablestyles, doctemplate = load_report_config()
+ return build_context(
+ {}, styles, doctemplate, tablestyles, {},
+ pathlib.Path.cwd(), pathlib.Path.cwd(), pathlib.Path.cwd(),
+ )
+
+
+def kinds(story):
+ return [type(f).__name__ for f in story]
+
+
+# --- _extract_list_block -----------------------------------------------------------
+
+def test_extract_list_block_heading_value_form():
+ assert _extract_list_block("_ul", ["a", "b"]) == ("ul", ["a", "b"])
+ assert _extract_list_block("_ol", ["a"]) == ("ol", ["a"])
+
+
+def test_extract_list_block_list_item_form():
+ assert _extract_list_block(None, {"_ul": ["a"]}) == ("ul", ["a"])
+ assert _extract_list_block(None, {"_ol": ["a"]}) == ("ol", ["a"])
+
+
+def test_extract_list_block_allows_suffix():
+ assert _extract_list_block("_ul_left", ["a"]) == ("ul", ["a"])
+ assert _extract_list_block(None, {"_ol_steps": ["a"]}) == ("ol", ["a"])
+
+
+def test_extract_list_block_ignores_non_list_blocks():
+ assert _extract_list_block("heading", ["a"]) is None
+ assert _extract_list_block("_ultra", ["a"]) is None # not _ul / _ul_*
+ assert _extract_list_block(None, {"_img": {}}) is None
+ assert _extract_list_block(None, {"a": 1, "b": 2}) is None
+
+
+# --- bare list is now paragraphs, not bullets --------------------------------------
+
+def test_bare_string_list_renders_as_paragraphs():
+ story = build_story({"Heading": ["first line", "second line"]}, ctx_value())
+ assert "ListFlowable" not in kinds(story)
+ assert kinds(story).count("Paragraph") >= 3 # heading + 2 paragraphs
+
+
+def test_single_item_list_is_a_paragraph_not_a_bullet():
+ # the original bug: one wrapped paragraph under a heading came out as a bullet
+ story = build_story({"Heading": ["just one paragraph"]}, ctx_value())
+ assert "ListFlowable" not in kinds(story)
+
+
+# --- explicit lists produce ListFlowables ------------------------------------------
+
+def test_ul_block_produces_list_flowable():
+ story = build_story({"Heading": {"_ul": ["a", "b", "c"]}}, ctx_value())
+ assert "ListFlowable" in kinds(story)
+
+
+def test_ol_block_produces_numbered_list_flowable():
+ story = build_story({"_ol": ["a", "b"]}, ctx_value())
+ lists = [f for f in story if isinstance(f, ListFlowable)]
+ assert len(lists) == 1
+
+
+def test_ul_as_list_item_form():
+ story = build_story({"Section": ["intro paragraph", {"_ul": ["x", "y"]}]}, ctx_value())
+ assert "ListFlowable" in kinds(story)
+ assert "Paragraph" in kinds(story)
+
+
+# helper (module-level context without a fixture, for direct calls above)
+_CTX = None
+def ctx_value():
+ global _CTX
+ if _CTX is None:
+ styles, tablestyles, doctemplate = load_report_config()
+ _CTX = build_context(
+ {}, styles, doctemplate, tablestyles, {},
+ pathlib.Path.cwd(), pathlib.Path.cwd(), pathlib.Path.cwd(),
+ )
+ return _CTX