From ee62ace884b835d029e5af7a932f5d1a7ecb8327 Mon Sep 17 00:00:00 2001 From: Connor Ferster Date: Thu, 13 Aug 2026 22:04:24 +0000 Subject: [PATCH] feat: explicit list syntax (_ul / _ol), bare lists become paragraphs Resolve the structural ambiguity where a YAML list under a heading meant either "several paragraphs" or "a bulleted list" depending on its contents. - A bare list is now always a sequence of content items: strings become paragraphs, mappings become subsections. It is never auto-bulleted. - Bullets and numbers are explicit: the new `_ul` and `_ol` constructs, intercepted structurally in build_story (not registry blocks) so they can compose with scoped text styles when those branches merge. - The implicit detections (list-of-strings -> bullets, integer-keyed mapping -> numbered list) are retired from dispatch; the check functions remain. - convert_ol accepts a list (positional numbering, nested lists nest) as well as a mapping (back-compat). This is a breaking content change (pre-1.0): existing documents relying on bare lists for bullets, or integer-keyed mappings for numbering, must adopt _ul / _ol. Migrates the test-data reports, all Examples, and the docs (document-structure, blocks reference); adds a design doc and tests. Co-Authored-By: Claude Opus 4.8 --- Examples/Document Configuration/report.yml | 78 ++++++++-------- Examples/Document variables/report.yml | 15 ++-- Examples/PDF Backgrounds/report.yml | 5 +- Examples/Simple example/report.yml | 25 +++--- Examples/YMPrint blocks/report.yml | 26 +++--- design/explicit-list-syntax.md | 100 +++++++++++++++++++++ docs/guide/document-structure.md | 60 +++++++------ docs/reference/blocks.md | 41 +++++++++ src/ymprint/content_converters.py | 21 +++-- src/ymprint/story_builder.py | 58 +++++++++--- tests/test-data/example_output1.pdf | Bin 57798 -> 57787 bytes tests/test-data/example_output2.pdf | Bin 78401 -> 78385 bytes tests/test-data/example_output3.pdf | Bin 38520 -> 38520 bytes tests/test-data/filled_forms.pdf | Bin 45804 -> 45804 bytes tests/test-data/report_example_1.yml | 34 +++---- tests/test-data/report_example_2.yml | 16 ++-- tests/test_content_converters.py | 16 ++-- tests/test_explicit_lists.py | 93 +++++++++++++++++++ 18 files changed, 439 insertions(+), 149 deletions(-) create mode 100644 design/explicit-list-syntax.md create mode 100644 tests/test_explicit_lists.py 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 94418d2fb737f2c33c80340c64852dc31f0d581a..cc7d26ba0dd3ad73111b3d62a607a5ea843ee1cf 100644 GIT binary patch delta 1591 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 diff --git a/tests/test-data/example_output2.pdf b/tests/test-data/example_output2.pdf index 0af00b74c561f3176985eb9560abfc4c5c10540a..785f6c68280c9f8e8db6a815e7929c81a2125649 100644 GIT binary patch delta 1010 zcmX^3gk|FsmJPb?OeV(D8?+eZ>$gt5U3AMppzZx%k?NUcQK73h=UHF=VzAfqvABW4 z2I-vUoMo4{zrV%e|B`eG;Y~%@N!j%6EFg*{&=`*Q?j&PRW1oKPl4MCGCgBrrQQE!Z_kl_eY(s0$E5Y&x4zX+&JARYJut1R zS!a#;#AI>vWag{Np>bXL$+#YO5*BZ|-`W(UBYBWUI2!UNI)U#lEvazWL_Q z*V#c|?@x1Bt`j@G;I=bU@9Zb?)fLL&o2xsYE~_`Rv`{br0fjsTE-=H`)Y!}nL(Ih3 z2vf|`1VhZs&=5n++yYa~!o(b;t{%x^GgBi|bjO&RSehE38E9^5YK|#pZj7PV)WQJW zf##+bD4v_nXUNDFY-wSPZiktnDW;f_p(VOHb3=0sb>_wT2n1W?^DrX6WQ%Vc=q9X=r8_ eYiGw*T#{H+Qc;we#$|49Vqngts_N?R#svV?Gjt>X delta 1014 zcmdn^gyrB9mJPb?Os1C83$z&J>(5U0?Z0Ip(DwePXz`NJ*S~&0j#_z}|Ldl^GS!?9 zR9{YFnX_Qk|9!%n8Mg_9rHHSLU}`m&vtOTnEKdDE!=oE@=e-*GHEVVx96d29&GmIJCL9wK z)A*>E`DwDGaN~q!|I0P3dKG=d8w`0`&KJ+PBeF1a;Z2{!sjWGJn?m`#=b!D)VsyQF zZSIu(`PI+gf4|&srWkeg)&4D2&(ljy>uuM^9;$Cu^VzH&de`urCtDup<4JSZSRHx4 zwY%6*!&jnFaHT`O*SC{9`b)!ePp#yy<8ys|`tCgWeHEJywM@Rg;b?r`>YLYYM8qG9 zIXt~$=lZvd7T2aNyS_m+ej(Ru-;B?O`!;-R5opm|s{gvqeaq5wH)UrlgdMpi?qVn= z{a^6)&EIYXp7mQcyRYpRI=43?PH;f+(_HWA6xHX~G{(V4|;eqsstNU!uAG@k> z+p?3->r26W$v=J;HPUO&$%Z~$8$f4AK-WSu{pc*>9TqwLlXr9 z5Kzcd-~uy@O^q##FvLtuEHK1O4Gl2F%#6%2#LO)*#VpK>LF$x{EH*PWGDmlexrya; zM?*&8dQ%HCG;_^O%}p_sn_3v5yUEeM%rk0kL=;oOjnqi6=8JmLCsUW%2+}zLt zL!r5`srmHfhK%~r7B0@lj*g~ArjE`=POi?zmgYuI=0JZq8#}o<8`;@dI-8i7S(rMR z7@C{8nz)*~Iy)IzI=fk#x*9qey1AIx#oF0%6_+Fyl~fd^rf~ta8X0q`s=E5SaRC5M C$#S0n diff --git a/tests/test-data/example_output3.pdf b/tests/test-data/example_output3.pdf index 6aeaefc5d28042e155e9858adb619c09dab3b466..63f9504f6ac00b4f0324117075d92eb526644111 100644 GIT binary patch delta 80 zcmWN@%L%|B5CuT64V!R(ejo(0PwXO91+2nD%JZ1aY4!?xg*Q&m0`Zb78@QQTAyNH* d0bEi1Jcw0cJj{{{HK*)@dQ}Mb)C-<_#t)wL6+Qp} delta 80 zcmWN_%L%|B5CuR;8#WR5`CAA9-)b4DDqs~JQl7_noLTc&^Z49gP(zI-?1ln^(nIpL c>}Ra^l?z1~nrGCMRbjmmNrHkWP~T^`AF$;Wf&c&j diff --git a/tests/test-data/filled_forms.pdf b/tests/test-data/filled_forms.pdf index fe5d2eb10ee0b57635a927b04d09ac978f34940e..48beaa903bd291e10cbd29b1110b220478044593 100644 GIT binary patch delta 80 zcmaF!l 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