Skip to content

Render every block child of a list item, cell, footnote, box and quote - #11

Merged
lgruen-cpg merged 4 commits into
mainfrom
fix/list-item-block-children
Sep 8, 2026
Merged

lgruen-cpg merged 4 commits into
mainfrom
fix/list-item-block-children

Conversation

@lgruen-cpg

@lgruen-cpg lgruen-cpg commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What

render_list built each <list-item> from item.findall('p') alone and joined the paragraphs into one line. A <list> or <def-list> nested as a sibling of the paragraph, and the item's own <label>, were dropped without a trace — every sub-bullet in a Bookshelf chapter's management and diagnosis sections, for instance. The same collect-a-fixed-subset shape recurred in a dozen other containers in both dialects (definition lists, boxed text, quotes, statements, glossaries, structured abstracts, the body itself, table cells, <table-wrap-foot> — whose <fn-group> footnotes were dropped — and the Elsevier list, def-list, display, quote, enunciation, table entry and footnote renderers).

How

One mechanism rather than a patch per site: a document-order content walker (jats._render_content, elsevier._Renderer._content) renders each child through its block renderer and each run of text and inline children between blocks as a paragraph, returning typed (tag, markdown) fragments. A tag without a block renderer is inline content, so nothing falls through silently; the only exclusions are named metadata children (sec-meta, permissions, object-id) and the children a caller renders itself (a label, a title). JATS block dispatch is one table, _BLOCK_RENDERERS; a caller that needs a tag rendered its own way (a structured abstract's <sec> as a bold title, a section's <ref-list> headed against the section title) passes an override, so document order is kept.

Three shared layout helpers in common turn the fragments into markdown: list_item stacks an item's blocks under its marker, indenting by the marker's width (CommonMark's content offset) so a nested list nests and a later paragraph continues the item; definition_item does the same for a - **term** — definition entry (the definition joins the term line only when it opens with a paragraph); md_cell joins a cell's blocks with <br>. A <label> becomes the marker only when CommonMark would parse it as one (common.list_marker: an ordinal such as 3. or 10) verbatim, a bare number with its period, a bullet glyph as -); any other label ((i), Step 1:, **(a)**, a math bullet) is the item's enumerator and opens the content behind a bullet, whatever the list type, so the line is a list item and its sub-list nests; a lead that would open a heading, quote or fence is escaped. A nested list is separated from the paragraph above by a blank line unless its first line can interrupt a paragraph (a bullet or 1.), since - Steps:\n 3. Third. would otherwise be a lazy continuation. Verified with pandoc. A flat list of single-paragraph items renders byte-identically to before.

Where to look

  • litdown/jats.py: _render_content, _BLOCK_RENDERERS (end of module), render_list, render_def_list, render_table / _render_table_foot.
  • litdown/common.py: list_marker, list_item, definition_item, md_cell, text_carrier.
  • litdown/elsevier.py: _Renderer._content, _para_typed, _list.
  • tests/test_block_content_unit.py: one hand-written document per container shape; every (multi-character) text node of the input must appear in the output, plus the exact nesting layout.

Also rendered where previously dropped

A section's <label> (joins the heading), a list's <title>, <term-head>/<def-head>, a term's inline markup (findtext read only the text before the first child), a labelled and captioned <media>/<graphic> in a paragraph, an <inline-graphic> (as an image), bare <mml:math>/<tex-math> in running text (as inline LaTeX instead of their source; a bare <alternatives> yields one alternative), a titled <app-group>, <bio>, and an Elsevier quote's <source>. Cells iterate <th>/<td> in document order rather than all <td> first. A table foot is italicised as a whole only when no asterisk inside it (an italic span, a literal significance marker) could pair with the wrapper — so a foot mentioning an italic gene name is now plain; dropping the wrapper for every foot would be the consistent alternative, at the cost of churning every table foot. Reference entries are blank-line separated with none trailing, so a mid-section <ref-list> leaves no triple newline (a document ending in references loses its trailing newline). Inline runs trim ASCII whitespace only (common.flat, as table cells and labels already did; headings and attribs still use str.strip()), so em-space indented pseudocode keeps its indentation; a run that is only non-ASCII whitespace still renders nothing.

Measurement

Over a corpus of 1,013 Bookshelf chapters (BITS XML; 961 render, 52 are <book-app>/<toc> units the wrapper refuses by design): 960 of 961 renderings change; content words (tokens with a letter or digit, excluding list markers and <br>) go from 8,096,392 to 8,324,705 (+228,313, +2.8%); no chapter loses words. Body/back text nodes absent from the output drop from 18,568 in 891 chapters to 6,713 in 77 — what remains is a <back><index> in one chapter (not rendered, before or after) and reference-field text (<supplement>, <comment>, <elocation-id>) the citation renderer does not emit, both outside this change.

Of the 39 PMC article fixtures, all 39 renderings change (every article ends in a reference list, which loses its trailing newline); 16 change beyond whitespace: list-bearing cells gain <br>-separated items, multi-paragraph footnotes keep their paragraphs, fn-group table footnotes and eLife's competing-interest/author-contribution groups appear, inline graphics render as images, a glossary's heading gains a blank line, foots with emphasis inside lose the italic wrapper; body text nodes missing from the output go from 1,111 to 1,091. Of the 11 Elsevier fixtures, 5 change (bullet-glyph and (1) labels now list items, a bulleted cell, a quote's <source>, a table placed at its anchor inside a list item instead of the end sweep); no body text node is missing before or after.

Release

Version bumped to 0.7.0 (rendered output changes for most documents; consumers caching renderings re-render). Publishing follows docs/releasing.md: after merge, publish a GitHub Release tagged v0.7.0; the release workflow checks the tag against pyproject.toml, builds, and publishes to PyPI via Trusted Publishing.

render_list built each <list-item> from item.findall('p') alone and
joined the paragraphs into one line, so a <list> or <def-list> nested
as a sibling of the paragraph, and the item's own <label>, were dropped
without a trace. Across a corpus of 1,013 Bookshelf chapters (BITS XML)
list items hold 2,951 nested lists — about 196,000 words, every
chapter's sub-bullets in its management and diagnosis sections.

The same shape — collect a fixed subset of children, drop the rest —
recurred in render_def_list, render_boxed_text, render_disp_quote,
render_statement, render_glossary, render_abstract's structured
sections, render_body, render_floats_group, table cells and
<table-wrap-foot> (whose <fn-group> footnotes were dropped), and in the
Elsevier _list, _render_deflist, _render_display, _render_quote,
_render_enunciation, table entries and footnotes. So the fix is one
mechanism rather than a patch per site: a document-order content walker
(jats._render_content, elsevier._Renderer._content) renders each child
through its block renderer — a <p> as its own fragments, a run of text
and inline children between blocks as a paragraph — and returns typed
(tag, markdown) fragments. A tag with no block renderer is inline
content, so nothing falls through; the only exclusions are named
metadata children (sec-meta, permissions, object-id) and the children a
caller renders itself (a label, a title). JATS block dispatch is one
table, _BLOCK_RENDERERS, keyed by tag.

The fragments are laid out by three shared helpers in common: list_item
stacks an item's blocks under its marker, indenting by the marker's
width (CommonMark's content offset) so a nested list nests and a later
paragraph continues the item; definition_item does the same for a
`- **term** — definition` entry; md_cell joins a cell's blocks with
<br>, a GFM row being single-line. The item marker is the <label> when
present (verbatim, as the Elsevier renderer already did), else the
ordinal or a bullet. A flat list of single-paragraph items renders
byte-identically to before.

Also rendered where previously dropped: a section's <label> (joins the
heading), a list's <title>, <term-head>/<def-head>, a term's inline
markup (findtext read only the text before the first child), a
labelled and captioned <media> or <graphic> in a paragraph, an
<inline-graphic> (as an image), bare <mml:math>/<tex-math> in running
text (as inline LaTeX instead of their source), a titled <app-group>,
<bio>, and an Elsevier quote's <source>. Cells iterate <th>/<td> in
document order rather than all <td> first. Inline runs trim ASCII
whitespace only (common.flat), as every other edge does, so an em-space
indented pseudocode line keeps its indentation.

Tests: one hand-written document per container shape, checked both for
the invariant that every text node of the input appears in the output
and for the exact markdown layout of the nesting.

Bump version to 0.7.0: rendered output changes for most documents with
nested lists, list-bearing cells, numbered sections or multi-paragraph
footnotes, so consumers that cache renderings need to re-render.
…lace

Review fixes for the block-content walker.

A verbatim <label> is not a CommonMark list marker: "• Outer\n  ◦ Inner"
parses as one paragraph, and a label three or more characters wide
("(i)", "Step 1:") indents its continuation paragraphs into an indented
code block. common.list_marker now resolves a label: an ordinal
("3.", "10)") is the marker verbatim, a bare number gets its period, a
bullet glyph becomes "-", and anything else is not a marker at all — the
list's default marker is emitted and the label opens the item's content
("- (i) First."). The indent follows the marker actually emitted. The
Elsevier renderer applies the same rule, keeping the inline rendering of
a non-marker label ("- **(a)** …"). Verified with pandoc: every shape
parses as a (nested) list.

A definition entry, a <statement> and an Elsevier <enunciation> join
their head onto the first block only when that block is a paragraph; a
fence, table or formula now starts beneath the head line.

A run whose ASCII-trimmed text is only non-ASCII whitespace
(<p>&#160;</p>) renders nothing, as before; the trim stays ASCII-only,
which table cells and labels already used (headings, attribs and
statement heads trim with str.strip()).

An Elsevier <textbox> without <textbox-body> no longer repeats its
label and head from the body walk. A paragraph nested in another run —
a list item's, a footnote's — restores the run's queued floats instead
of resetting them, so a float anchored there is placed at the
paragraph, not in the end sweep.

A nested, titled <def-list> is preceded by a blank line so its title
does not lazily continue the entry above. A table foot part that is one
italic span is unwrapped before the foot is italicised, so an
<attrib><italic> no longer renders bold. A bare <alternatives> in
running text yields one alternative in _formula_body's preference
order instead of every alternative in turn. A structured abstract's
<sec>s, and a section's <ref-list>, are dispatched inside the walk (an
overrides table per caller), keeping document order for a paragraph
that follows them.

Tests: the text-node invariant now uses multi-character tokens
throughout (a one-character node matches almost any output) and asserts
that; layout-only shapes — a bullet-glyph label, an <alternatives> —
are checked for their markdown alone. New cases: bare-number, ordinal,
glyph, roman and word labels, a definition opening with code, a
statement opening with a table, a titled nested def-list, an italic
attrib foot, whitespace-only paragraphs, a bold Elsevier label and a
body-less textbox.
…foot

Second-pass review fixes for the block-content walker.

Only a bullet or a "1." item can interrupt a paragraph in CommonMark;
"- Steps:\n  3. Third." lazily continues the paragraph, and an empty
first nested item ("-" alone) underlines it as a setext heading.
common.list_item now puts a blank line before a nested list whose first
line is not "[-*+] x" or "1[.)] x" — the same path lays out definition
entries and Elsevier items.

A label that is not a marker is the item's enumerator, so it opens the
content behind a bullet whatever the list type ("- (a) Alpha.", not
"1. (a) Alpha."); the marker is decided on the stripped label (so a
glyph followed by a no-break space counts), the glyph set gains the
dashes and the remaining common bullet shapes, and a lead that would
open a heading, quote or fence is backslash-escaped. An Elsevier label
with no text but an inline rendering (a <math> bullet) is kept as the
lead rather than dropped. list_marker's docstring says that a verbatim
ordinal keeps the source numbering in the markdown while a renderer
renumbers from the first item.

A table foot is italicised as a whole only when no asterisk inside it
— an italic span or a literal significance marker — could pair with the
wrapper, so a partly italic <attrib> no longer renders
"**Adapted* from Doe.*" (unwrapping the inner emphasis was tried and
rejected: it also ate literal asterisks, "DQB1*0302"). A bare
<alternatives> prefers its <tex-math>,
then its <math> whether namespaced or not, before a graphic, so an
un-namespaced <math> beside a <graphic> renders as math. Reference
entries are blank-line separated with none trailing, so a <ref-list>
inside a section no longer leaves a triple newline before the paragraph
that follows it (a document ending in references loses its trailing
newline).

Tests: the italic-foot assertion is anchored on the preceding blank
line and asserts no bold; new shapes cover a nested list opening with
"3.", "10)", a bare "3", "0" and an empty item, an order-typed list with
"(a)" labels, "#"/">" labels, an <alternatives> with un-namespaced math,
a glyph-with-NBSP label, an Elsevier math label and a mid-section
<ref-list>. Verified with pandoc that each shape parses as intended.
@lgruen-cpg
lgruen-cpg marked this pull request as ready for review September 8, 2026 06:03
@lgruen-cpg
lgruen-cpg requested a review from folded as a code owner September 8, 2026 06:03
A table foot was set in italics as a whole. The wrapper collided with
emphasis inside the foot — an italic gene name at its edge doubled up
to bold — and with the literal asterisks footnotes use as significance
markers; making the wrapper conditional on the foot's content left a
foot's rendering depending on whether it mentions an italic gene. So
neither dialect wraps the foot: it is plain text below the table, its
footnote markers and its own markup as they are.

@folded folded left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realised that now scr needs to be able to post issues for me. :)

@lgruen-cpg
lgruen-cpg merged commit 9ea096d into main Sep 8, 2026
5 checks passed
@lgruen-cpg
lgruen-cpg deleted the fix/list-item-block-children branch September 8, 2026 06:58
themis-mirror Bot pushed a commit to populationgenomics/themis that referenced this pull request Sep 8, 2026
Bumps the exact `litdown` pin from 0.6.0 to 0.7.0 in both dependency
groups that carry it (`litcache`, `convert_worker`) and re-locks.

**Why.** litdown 0.6.0 built a `<list-item>` from its paragraphs alone,
so a nested `<list>`, a `<def-list>` and the item's own `<label>` were
dropped without a trace; the same collect-a-fixed-subset shape recurred
in table cells, footnotes, boxed text and quotes in both dialects. Every
sub-bullet in a GeneReviews chapter's management and diagnosis sections
went missing this way. 0.7.0 ([release
notes](https://github.com/populationgenomics/litdown/releases/tag/v0.7.0),
[populationgenomics/litdown#11](populationgenomics/litdown#11))
renders each container in document order and lays the blocks out per
CommonMark, so a nested list nests and a later paragraph continues its
item.

**Effect on stored renderings.** Rendered output changes for most
Bookshelf chapters (960 of 961 GeneReviews chapters change; content
words grow 2.8%, no chapter loses words) and for most PMC articles (a
trailing newline after the reference list, and real content in 16 of 39
fixtures). Nothing is re-rendered by this PR: a paper converted again
gains a rendering beside the old one, stamped `converter_version` 0.7.0,
and the read path serves the newest of the highest-fidelity route. The
GeneReviews chapters are being imported on dev by a one-off operator run
against this version.

**Where to look.** `pyproject.toml` (two pin lines), `uv.lock` (the
`litdown` entry only). litdown is age-gate-exempt as a first-party
package, so the fresh release resolves.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants