Skip to content

fix(epub-codec): stop four XHTML container readers from silently dropping content - #996

Open
Mearman wants to merge 42 commits into
mainfrom
fix/epub-codec-silent-content-drops
Open

fix(epub-codec): stop four XHTML container readers from silently dropping content#996
Mearman wants to merge 42 commits into
mainfrom
fix/epub-codec-silent-content-drops

Conversation

@Mearman

@Mearman Mearman commented Sep 5, 2026

Copy link
Copy Markdown
Member

Fixes #994

Started as four container-reading functions in src/xhtml/read.ts that only recognised a fixed set of child shapes and dropped anything else outright, with no diagnostic and no fallback -- stricter than the honest degrade-with-diagnostic policy the rest of the package follows. Chasing the same shape through the rest of the reader (and then the writer) turned it into a longer pass; this is what actually landed.

Reading. readPre splices an <img>'s alt text into a <pre>'s extracted text instead of textContent() silently discarding it (epub/image-pre-unsupported -- the image itself still can't become a real block inside a plain-text content model). readTable reads a <caption> as an ordinary paragraph immediately before the table (epub/table-caption-unsupported), recovers content sitting outside any row/caption/<col> -- directly inside <table>, inside a <thead>/<tbody>/<tfoot>, or inside a <colgroup> -- the same way (epub/table-content-unrecognized), recovers a <tr>'s own content sitting outside any cell as its own cell (epub/table-row-content-outside-cell), and no longer drops every caption after the first when a table carries more than one (epub/table-duplicate-caption). readDefinitionList recurses into a <div> wrapping dt/dd pairs (legal HTML5, no diagnostic needed since the wrapper carries nothing to lose) and recovers genuinely stray content -- a bogus wrapper, stray text, a stray <img> -- via epub/definition-list-content-outside-entry. readList recovers content sitting outside any <li>, both between/after real items (attaches to the preceding item's own nesting) and before the very first one (recovered at the list's own enclosing membership), via epub/list-content-outside-item.

Alongside those: <style>/<noscript> are now skipped in body content like <script>/<template> already were, instead of leaking their own raw content into extracted text. A CDATA section reads exactly like an ordinary text node everywhere in the XHTML path (inline runs, stray-content collection, <pre> extraction, OPF metadata via the new decodedTextContent), rather than being silently excluded. A run-level construct (most often a footnote reference) carried by inline content built directly into a heading, table caption, table cell, dt/dd, or figcaption is preserved on that paragraph's own constructs field -- previously only an ordinary <p> kept it -- and a construct-only inline segment with no text of its own, sitting alone between two block siblings, is kept alive rather than dropped as if empty.

Preformatted content. document-schema.js gained ContentParagraph.preformatted, a general "this paragraph's whitespace is significant" signal independent of codeLanguage. readPre stamps it on every paragraph it produces, and the writer now trusts that flag instead of inferring "this came from a <pre>" from run count or a single-monospace-run heuristic -- a footnote reference nested inside a <pre> splits it into more than one run with no bearing on whether it's preformatted, which the old heuristic silently misclassified.

Writing. writeList dispatches a list item's own anchor content through the same horizontal-rule/preformatted/ordinary-runs check writeParagraph uses, so a <pre> or <hr> nested directly inside an <li> keeps its own block shape instead of degrading to bare inline runs. A heading's own runs are written directly (writeRunsToNodes), never through that dispatcher: h1-h6 permit only phrasing content, and both <pre> and <hr> are flow content, so nothing may write either one inside an <hN> regardless of what a foreign producer's input looks like. The writer's shared run-range walk now reaches a construct extent sitting at the very end of a run sequence -- a construct-only paragraph with zero runs, or a table caption containing nothing but a footnote reference -- instead of never entering its own loop body, and a point anchor (startRun === endRun) sitting strictly inside a run sequence preserves the run at that index instead of silently deleting its text.

Testing

  • Unit tests across src/xhtml/read.test.ts and src/xhtml/write.test.ts cover every shape above, including write-then-reread round trips for the construct-preservation and point-anchor fixes.
  • src/diagnostics-coverage.test.ts extended so every new diagnostic code counts as reachable in the coverage sweep.
  • README's own gotchas section rewritten to describe the actual current behaviour.
  • pnpm exec turbo run _lint _typecheck _build _test _test:workers --filter=epub-codec all clean.

Comment thread packages/epub-codec/src/xhtml/write.ts Fixed
Comment thread packages/epub-codec/src/xhtml/write.ts
@Mearman
Mearman marked this pull request as ready for review September 5, 2026 19:48
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-05T19:56:41.863617Z 60ca8b5 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…ping content

Four container-reading functions in the XHTML mapping only recognised a
fixed set of child shapes and dropped anything else outright, with no
diagnostic and no fallback -- a stricter failure than the honest
degrade-with-diagnostic policy the rest of this package follows.

readTable iterated element.children and only descended into tr/thead/tbody/
tfoot, so a <caption> -- a legal direct child of <table> per the HTML5
content model -- fell through to an empty array and vanished along with
everything inside it. It is now read as an ordinary paragraph immediately
before the table, exactly like a <figcaption>'s own treatment (its own
direct-child <img> degrades to alt text for the identical reason), with a
new epub/table-caption-unsupported diagnostic naming the one real loss:
document-schema.js's ContentTable has no field of its own for a caption's
distinct tag, so a same-format round trip cannot tell it apart from any
other paragraph before the table.

readDefinitionList only recognised a dt/dd that was a direct child of the
<dl>, so a <dl> wrapping one or more dt/dd pairs in a <div> -- legal HTML5,
used by producers that want a per-entry styling hook -- lost the whole
wrapped group. Recursing into a <div> child finds the pairs it wraps exactly
as if they sat directly in the <dl>. No diagnostic: the wrapper carries no
property document-schema.js's vocabulary can express, identical to every
other <div> this package already reads transparently.

readList only recognised an <li> child, so a <ul>/<ol> nested directly as a
sibling rather than wrapped in its own <li> -- not valid HTML5, but a shape
real producers and converters emit -- lost the entire nested list, and any
other stray content in that position (a bare <img>, stray text) vanished the
same way. Stray content following a <li> is now fed through the identical
readContainerChildren dispatch that <li>'s real children already use, under
that item's own list membership: a stray list becomes a properly nested list
one level deeper sharing the enclosing numId, a stray image becomes its own
real block, and stray text becomes its own paragraph, all via one mechanism
rather than a hand-rolled special case, with a new
epub/list-content-outside-item diagnostic. Content before the very first
<li> has no preceding item to attach to and is still dropped, unchanged from
prior behaviour.

readPre extracted a <pre>'s content with textContent(), which recurses into
element children collecting only text and so silently discards any <img>
found anywhere inside, at any depth. A <pre> block's own content model is
plain text, so an embedded image genuinely cannot become a real
ContentImageBlock the way one reached through readContainerChildren can --
there is no block list to insert one into, the same structural constraint
inline.ts's own image fallback already applies. Its alt text is now spliced
into the extracted text in its place, with a new epub/image-pre-unsupported
diagnostic naming the loss.

Confirmed by execution in #994.


Adds the issue's own reproduction cases for a <pre>/<code> block's <img>
(spliced into the extracted text and one nested a level deeper inside
<code>), a <table>'s own <caption> (text and image both), a <dl> wrapping
one dt/dd pair in a <div> plus several such groups in sequence, and a <ul>
nested directly as a sibling of <li> plus a stray <img> in the same
position -- the case the fix commit's own diagnostic and text call out as
the highest-impact of the four. A companion case pins that content before
the very first <li> still drops, unchanged from prior behaviour.

Also extends the diagnostics-coverage sweep so all three new
EpubDiagnosticCodes entries (image-pre-unsupported,
table-caption-unsupported, list-content-outside-item) count as reachable.
…osed

The direct-child-<img> gotcha's own trailing clause named four container
shapes as genuinely silent gaps tracked in #994: a <table>'s own <caption>,
a <dl> wrapping dt/dd pairs in a <div>, a <ul>/<ol> nested directly as a
sibling of <li>, and an <img> inside a <pre>/<code> block. Three are now
fully recovered (caption as a preceding paragraph, div-wrapped dl groups by
recursion, sibling list/stray content as a continuation of the preceding
<li>); the fourth remains a genuine structural limit -- a <pre>'s content
model is plain text, so an embedded image still cannot become a real
ContentImageBlock -- but no longer vanishes silently either, since its alt
text is spliced into the extracted text with a diagnostic.
…ray list content

readList treated every non-<li> child of a <ul>/<ol> as malformed stray
content, including plain whitespace text nodes between <li> siblings and
<script>/<template> elements. The HTML Standard (3.2.5.1) requires
inter-element whitespace to be ignored when checking an element's content
model, and states the <ul>/<ol> content model as "zero or more li and
script-supporting elements", making <script>/<template> legal siblings
of <li>.

The pretty-printed indentation nearly all real-world HTML uses was firing
a spurious epub/list-content-outside-item diagnostic on almost every
list, and a <script>/<template> element was routed through
readContainerChildren (which has no case for either tag), leaking raw
script source into content as a bogus paragraph under the preceding
item's list membership.

Both are now filtered out before stray-node collection: whitespace-only
text nodes and script-supporting elements are skipped entirely, with no
collection, no diagnostic, and no content emission.
readTable emitted a bogus {kind: "paragraph", runs: []} for an empty
<caption>, plus a spurious epub/table-caption-unsupported diagnostic,
contradicting this package's own documented rule (already enforced by
readContainerChildren's flush guard elsewhere) that an empty or
whitespace-only paragraph is dropped entirely on read.

The caption's runs are now checked before emitting anything: when they
are all whitespace, the caption carries nothing to lose, so it is
dropped with no diagnostic and only the table itself is returned.
appendElement's default case treated <script>/<template> as an ordinary
inline container, recursing into it and emitting its raw content (JS
source, or a <template>'s own inert subtree) as plain document text
whenever one was reached while building a flat run sequence -- inside a
<p>/<td>/<figcaption> directly, or nested arbitrarily deep inside any
other element appendElement recurses through. Script-supporting elements
are never legitimate document text per the HTML Standard's own content
model, so they now short-circuit to a no-op in the same switch dispatch
that already special-cases sub/sup and img, closing the leak universally
rather than only at the one position read.ts's own isScriptSupportingElement
check happened to guard.
readList dropped every whitespace-only text node sitting directly inside
a <ul>/<ol> before it could ever reach flushListStrayContent's own
stray-content collection, rather than only suppressing the diagnostic
that content would otherwise cause. The HTML Standard's "must be ignored
when establishing whether an element's contents match the content model"
rule (section 3.2.5 "Content models") governs conformance-checking alone,
not deletion of the character data itself, so a genuine inter-element
space between two stray inline siblings was being discarded outright,
silently joining adjacent words on read ("foo bar" becoming "foobar").

Whitespace text now flows into strayNodes exactly like any other stray
content, and flushListStrayContent's own guard skips the diagnostic (and
recovery, since there is nothing to recover) only when the *entire*
collected run of stray content, once actually read through a throwaway
sink, carries nothing but whitespace -- real content mixed with real
whitespace still gets the full diagnostic-and-recovery treatment, with
the whitespace preserved as part of it. The previous comment on this
rule also cited the wrong HTML Standard section (3.2.5.1, "The 'nothing'
content model") for the quoted sentence, which actually lives in 3.2.5.
…tion

readTable built its recovered caption paragraph from only captionInline's
runs, discarding captionInline.constructs entirely -- silently dropping
any run-level construct extent inside a <caption> (most notably a
footnote reference) with no diagnostic, since the field simply never
made it onto the returned ContentParagraph. Splices constructs onto the
paragraph the same way readContainerChildren's own flush already does,
only when there is at least one to carry.
…rvation behaviour

The gotchas bullet on #994's four content-drop fixes described the
<caption> recovery unconditionally as "read as an ordinary paragraph
immediately before the table", which stopped being the whole story once
an empty or whitespace-only <caption> started dropping entirely instead.
Splits the claim into both cases, and notes that a run-level construct
the caption's own inline content carries now rides the recovered
paragraph's own constructs field rather than being discarded.
…on is empty

flushListStrayContent decided whether to recover stray content sitting
directly inside a <ul>/<ol> by building the collected nodes' inline runs
and checking whether the resulting text was blank -- but buildInlineRuns
only ever produces TEXT runs, so any stray block-level construct whose
text projection happens to be empty (a resolved image with no alt text,
an <hr>, a table or nested list whose only content is such an image) was
misjudged as whitespace-only and silently dropped, with no diagnostic,
indistinguishable from real pretty-printed whitespace.

Replace the speculative text-only probe with the real
readContainerChildren result: recover and report whenever it yields any
block at all, dropping only when it genuinely yields none (true
inter-element whitespace). This also removes a redundant double-read of
the same nodes -- the probe built inline runs through a throwaway sink
purely to answer a question the real read already answers correctly.
readPreText walks a <pre>'s children on its own recursion rather than
calling into src/xhtml/inline.ts's appendElement, so the script/template
skip that function's own comment calls "universal" never actually
reached a <pre>: both tags are legal children of <pre> per the HTML
content model, and their content (raw JS source, an inert DOM subtree)
still leaked into the extracted document text.

Give readPreText the identical skip branch so a <script>/<template>
sitting anywhere inside a <pre> is dropped rather than read as text,
matching what already happens everywhere else in this module.
…irst <li>

flushListStrayContent dropped any content collected before the first <li>
in a <ul>/<ol> with no diagnostic at all, on the theory that it had no
preceding item to attach to. That silently discarded real content: a
<ul> nested as a sibling before any <li> (issue #994's own headline
repro, `<ul><ul><li>b</li></ul><li>a</li></ul>`) lost the entire nested
list, and plain stray text before the first item vanished the same way.

Recover this content instead, through the same readContainerChildren
dispatch already used for content between/after real <li> siblings, but
without any list membership of its own -- it lands in the returned
block sequence immediately before the list's own real items, matching
what a browser renders for this malformed shape. The recovery-or-drop
decision and the LIST_CONTENT_OUTSIDE_ITEM diagnostic now cover both
cases uniformly, and the underlying read runs exactly once either way:
its result is always either fully used or the empty list nothing was
minted or reported for, never computed and then discarded.
…s empty

readTable dropped a <caption> as empty purely by checking whether its
built runs were all-whitespace, using Array.prototype.every over the
runs array -- vacuously true when the array is empty. A caption whose
only content is a footnote-reference anchor with no text of its own
(`<caption><a epub:type="noteref" href="#fn1"></a></caption>`) produces
zero text runs but one real RunConstructExtent, so the text-only guard
discarded a construct that had nothing else in the caption to carry it.

Only drop the caption when it carries neither text nor any construct.
…iblings

readContainerChildren's own segment flush decided whether phrasing
content sitting between two block-level siblings was empty by checking
only whether its built text runs were all-whitespace -- the same
Array.prototype.every-on-an-empty-array pattern that caption reading
carried. A bare footnote-reference anchor with no text of its own
(`<a epub:type="noteref" href="#fn1"></a>` sitting directly between two
block siblings) produces zero text runs but one real RunConstructExtent,
so the guard dropped the whole segment, construct included, treating it
as indistinguishable from real inter-element whitespace.

Only drop the segment when it carries neither text nor any construct.
…headings and ids

containsHeading, buildIdElementMap, and readXhtmlBody's own footnote-
anchor prescan each walk arbitrary XML descendants directly, bypassing
buildInlineRuns's own appendElement dispatch, which is the only place
that already treats <script>/<template> content as inert. As a result:

- A heading sitting inside a <template> inside a <blockquote> incorrectly
  suppressed the blockquote's own division construct, even though the
  heading is never real, readable document content.
- An id living only inside a <template> was indexed into the id->element
  map, letting a footnote reference resolve against a target that will
  never actually be read.
- A footnote-reference anchor living only inside a <template> could seed
  footnoteTargetIds, causing an unrelated, genuinely live body element
  sharing that target id to be wrapped as a footnote body nothing real
  ever referenced.

Add a shared isInertContainer(tag) guard and apply it in all three
places, plus a local elementsWithTagSkippingInert for the footnote-
anchor prescan (src/xml/query.ts's own elementsWithTag stays generic,
since it is shared by other callers with no reason to assume this
policy).
…lt directly from inline content

A table caption already kept a footnote-reference construct extent carried by its own inline
content, but a figcaption, a dt/dd, and a table cell each built their paragraph as
`{ kind: "paragraph", runs: inline.runs }` directly, discarding buildInlineRuns's own constructs
array outright -- the identical defect shape reproduced four more times rather than fixed once.

Extract the caption's own constructs-attachment logic into a shared constructsField helper and
funnel every such paragraph (heading, caption, table cell, dt, dd, figcaption, and
readContainerChildren's own segment flush) through it, so a construct collected while building
inline content is never dropped just because the paragraph carries no other property worth
spreading in.
…inition list

readDefinitionListEntries silently dropped any <dl> child that was not dt/dd/div (a stray <p>,
stray text, a stray <img>, or a one-tag variant like <section> wrapping dt/dd instead of <div>)
with zero diagnostics -- the same defect class #994 was filed about, reachable
here one tag away from the shape that issue's own fix already covers.

Apply the identical treatment readList already uses for its own analogous whitelist: route any
stray child through readContainerChildren and report a real diagnostic
(epub/definition-list-content-outside-entry) naming the loss, rather than silently dropping it.
<div> stays the one wrapper this narrower rule recognises without a diagnostic, since it is the
one shape HTML5's own <dl> content model actually names as legal; a non-conformant wrapper's own
dt/dd children lose their distinct term/definition treatment once routed through
readContainerChildren, degrading to plain concatenated text -- a real fidelity cost, but a
text-preserving one.
…ide them

readTable's row loop only recognised tr/thead/tbody/tfoot/caption as valid table children,
dropping anything else (stray prose, a stray div) with no diagnostic; its row loop only
recognised td/th as valid row children, dropping stray text or elements sitting directly inside
a <tr> the same silent way. findChildElement also only ever resolved the FIRST <caption>,
discarding a second one outright.

Apply the same general stray-content-recovery-with-diagnostic treatment already used elsewhere
in this file to both whitelists: table-level stray content is recovered via
readContainerChildren and reported through epub/table-content-unrecognized, positioned
immediately before the table (a table's own rows fold into one indivisible ContentTable block,
so there is no position WITHIN it to reinsert interleaved stray content into); row-level stray
content is recovered as its own cell in the row's own column sequence via
epub/table-row-content-outside-cell. A <table> carrying more than one <caption> now has every
caption beyond the first read as its own paragraph too, via epub/table-duplicate-caption, rather
than silently discarding it.
…redicate

isInertContainer, isScriptSupportingElement, an inline tag-literal check inside readPreText, and
a dedicated switch case in buildInlineRuns's own per-node dispatch (inline.ts) were four separate
copies of the identical script/template check, so a future tag addition to one could silently
miss the others.

Move the check to context.ts as one shared isInertElement predicate, used everywhere: read.ts's
buildIdElementMap, elementsWithTagSkippingInert, containsHeading, readPreText, readList,
readDefinitionListEntries, and readTable, plus inline.ts's own appendElement. Pure refactor, no
behavioural change -- the tag set stays script/template only.
…>/<template>

<style> and <noscript> appearing in <body> fell through to the default inline-container handling
and leaked their raw text as document prose -- readStyleResidue only quarantines <head>-level
styles, and inline.ts's own appendElement only ever guarded against <script>/<template>.

Extend the shared isInertElement predicate to also cover <style> (CSS, exactly like the
<head>-level residue this package already quarantines rather than interprets) and <noscript>
(scripting-disabled fallback markup this package cannot reliably distinguish from a "please
enable JavaScript" placeholder). Both are now skipped everywhere the predicate is already
consulted, including buildInlineRuns's own run-building recursion, so they can never leak into
document text regardless of where they sit.
…very

Content recovered before a list's very first <li> was documented and diagnosed as landing "with
no list membership of its own" and rendering "exactly what a browser renders for this malformed
shape" -- both false whenever the enclosing <ul>/<ol> is itself nested inside another list's
<li>: the recovery reuses the plain `state`, so it inherits state.listItem when one is already
set, and a browser indents such content at the outer item's own depth while this recovery always
emits it as a shallower top-level sibling.

Reword the doc comment and the diagnostic message to state the real behaviour -- order preserved,
membership inherited from the enclosing context (none only when that context has none),
nesting depth not necessarily preserved -- and add a regression test proving a nested case
actually inherits the outer <li>'s own membership rather than carrying none. Also brings a
handful of comments left saying "<script>/<template>" back in sync with isInertElement's
now-wider <style>/<noscript> tag set.
…de any <tr>

readTable's own thead/tbody/tfoot handling filtered a row group's children down to just <tr>
elements, silently dropping anything else -- stray text, a stray paragraph, a stray image, an
entire nested list, a stray caption -- with zero diagnostics, one level of nesting deeper than the
table-direct and row-direct stray-content recovery this file already applies. A row group's own
content model per the HTML Standard is "zero or more tr and script-supporting elements", the
identical malformed-content shape the table-direct case already recovers, so
collectRowGroupRows folds a row group's own non-tr, non-inert children into the same shared
strayNodes accumulator readTable already recovers and reports through, rather than needing a
second recovery path or diagnostic.
…tent recovery's own comment

flushListStrayContent's own comment still said an inert element (script/template/style/noscript)
"never reaches this function at all", flatly true only for one sitting as a direct child of the
<ul>/<ol> itself -- readList's own loop filters those out before collection -- but false for one
nested a level or more deeper inside a stray wrapper (e.g. a <script> inside a stray <div>), which
does reach this function's own readContainerChildren call and resolves to zero blocks there
instead. flushDefinitionListStrayContent's own comment already states this narrower, correct claim
for the identical <dl> case; bring this comment in line with its sibling.
…nline content inside a <pre>

readPre built its paragraph from readPreText's own flat-string walk and never called
buildInlineRuns, so a run-level construct (a footnote reference, most commonly) sitting inside a
<pre> was silently dropped with no diagnostic at all -- worse than the <img>-in-<pre> case, which
at least degrades with a diagnostic naming the loss.

containsFootnoteReference cheaply checks whether a <pre>'s own subtree carries a recognised
footnote-reference anchor anywhere, at any depth, mirroring containsHeading's own inert-skipping
descendant walk. Only when it does does readPre pay for readPreRuns, a run-splitting twin of
buildInlineRuns that still preserves a <pre>'s own verbatim whitespace (never routing through
buildInlineRuns' own normalizing walk) while bracketing a footnote reference's own text as its own
run range a RunConstructExtent can point at, rebasing any nested call's own construct indices onto
the outer run sequence at the point of the merge. The common case -- no footnote reference anywhere
in the block -- still takes the original, unchanged single-run readPreText path.
…e-reference construct

A heading paragraph has carried its own run-level construct via constructsField since that helper
was extracted, but unlike every sibling site sharing it (a table caption, a table cell, a
<dt>/<dd>, and a <figcaption>), it had no dedicated test proving a footnote reference survives on
a heading built directly from inline content.
…im-whitespace paragraphs

ContentParagraph gains an optional preformatted boolean naming a paragraph whose
runs carry significant whitespace that must survive verbatim (HTML/EPUB's <pre>,
ODF's Preformatted_Text style). Independent of codeLanguage, and independent of
run count or content: a construct nested inside a preformatted block can split
its content into further runs with no bearing on whether the block itself is
preformatted, so a reader that already knows a paragraph came from a verbatim
construct sets this directly rather than leaving a writer to infer it from run
shape, which is never a reliable signal for the fact.
…d flag, not run count

isPreBlockParagraph used to recognise a <pre>/<code> paragraph only via
codeLanguage or a single monospace run embedding a newline, so a <pre> with no
language class whose content includes a recognised construct (readPreRuns
splits a footnote reference into its own run range) silently round-tripped as
an ordinary <p>, losing the block's own verbatim whitespace. readPre now stamps
every paragraph it produces with document-schema.js's own preformatted flag
regardless of run count, and the writer checks that flag first; a recognised
<pre> paragraph's runs and constructs write through the new writePreRunsToNodes
(sharing writeRunsToNodes' extent-finding walk via the extracted
writeRunRangeNodes, but never splitting an embedded newline into a <br/>, since
<pre>'s content model has no <br> handling to read one back from).
… the XHTML reading path

Every text-bearing walk in this package (buildInlineRuns, readContainerChildren's
own phrasing/block split, readPre's three text-extraction paths, every
stray-content collector in read.ts, and xml/query.ts's own textContent)
dispatched on node.type === "text" alone, silently dropping a CDATA section --
the standard XML idiom a producer reaches for when its own literal text would
otherwise need escaping (a code sample, or any content containing a raw </&).
xml/node.ts's isTextLikeNode is now the one shared predicate every such walk
dispatches on, and xml/entities.ts's decodeTextLikeNode is the one place that
folds a CDATA node's raw, never-entity-encoded value in alongside a decoded
text node's, so a caller combining the two never runs CDATA content back
through decodeEntities. textContent itself now decodes internally rather than
leaving its callers to decode the concatenated result, so its own CDATA
descendants are never double-decoded; its four call sites in opf/metadata.ts
drop their now-redundant decodeEntities wrap accordingly.
…y <col>

A <table>'s own <colgroup> was skipped wholesale on the reasoning that its only
legal content (<col>, and script-supporting elements) carries nothing
document-schema.js's own vocabulary can represent -- true for conforming
content, but any other content sitting directly inside a <colgroup> (a stray
<p>, stray text, a stray <img>) is not valid HTML5 and was silently discarded
with no diagnostic. collectColgroupStrayContent recovers it the same way
collectRowGroupRows already does for a <thead>/<tbody>/<tfoot> one level up,
feeding readTable's own shared strayNodes accumulator so it is recovered
immediately before the table via the existing table-content-unrecognized
diagnostic.
…list item

writeList built a list item's own anchor content via writeRunsToNodes alone,
never consulting the horizontal-rule/preformatted dispatch writeParagraph
itself already used for every other paragraph. A <pre> nested directly
inside an <li> (ordinary HTML: <li>'s content model is flow content) read
back correctly as a preformatted paragraph but was then written out as an
ordinary run sequence, destroying its verbatim whitespace; a <hr> nested the
same way vanished from the written <li> entirely, since writeRunsToNodes on
an empty-runs paragraph with no constructs produces zero XML nodes.

writeHeading has the identical bypass shape, so it now routes through the
same shared dispatch (writeParagraphAsEmbeddedNodes) for consistency, even
though a heading's phrasing-only content model makes a <pre>/<hr> heading
unreachable from a real read.
…ontract

textContent is a published export re-exported from this package's own
barrel. An earlier commit on this branch made it decode entities and
include CDATA content internally, when it previously returned raw,
undecoded text-node content only -- exactly the contract this package's own
opf/metadata.ts relied on before that change, wrapping its result in
decodeEntities itself. A stale external caller doing the identical
decodeEntities(textContent(x)) would now silently double-decode (a literal
"&amp;amp;" round-tripping to "&" instead of the correct "&amp;").

textContent is reverted to its original raw, text-node-only behaviour. The
CDATA-aware, decode-internally behaviour lands instead as a distinctly
named sibling, decodedTextContent, which opf/metadata.ts's four Dublin Core
readers now call instead, dropping their own now-redundant decodeEntities
wrap exactly as before.
…ph dispatcher

h1-h6 permit only phrasing content per the HTML Standard, while <pre> and <hr>
are flow content, so routing writeHeading through the same horizontal-rule/
preformatted/ordinary-runs dispatch writeList uses for a list item's anchor
content let a heading styled entirely in a monospace font with an embedded
newline -- tripping isPreBlockParagraph's own legacy heuristic for a foreign
producer's <pre> -- write a non-conformant <pre> nested inside an <hN>, a
shape real EPUB validators reject.

"This reader can't produce that shape" was not a safe argument for the
writer: the writer's job is round-tripping whatever a foreign producer's
document actually contains, and a heading's content model already rules out
<pre>/<hr> unconditionally regardless of provenance. writeHeading now writes
its runs via writeRunsToNodes directly, matching every other call site's own
reasoning for why a heading was never routed through the shared dispatcher
in the first place.
…wn run on write

writeRunRangeNodes' own while loop only iterated while there was at least one
run left (index < runs.length), so a paragraph with zero runs -- a bare
footnote-reference construct sitting alone between two block siblings, or a
table caption containing nothing but one -- never reached its own construct
extents at all. The reader already recovers both shapes correctly; the
writer silently dropped the construct instead, producing an empty <p></p>
and orphaning the footnote body it once pointed at. The loop now bounds
itself with index <= runs.length so an extent sitting at the very end of
the run sequence is still reached.

Reachable through the same function: a point anchor (startRun === endRun)
sitting strictly inside a paragraph's run sequence used to advance the walk
past the run at that same index without ever rendering it, silently
deleting that run's own text instead of merely failing to wrap it in the
anchor. A point anchor now falls through to render the run at its own index
after emitting the (empty) anchor element, rather than treating its empty
range as consuming that index the way a real non-empty extent's endRun does.

Adds write-then-reread round-trip tests for both the bare-empty-paragraph
and empty-caption construct shapes, and for the point-anchor case, none of
which the prior round's read-side fix alone exercised.
… not just its intent

The field comment named HTML/EPUB's <pre> and ODF's Preformatted_Text style
as the constructs this flag exists to carry, which reads as if both already
set it. Only epub-codec's readPre does. markdown-codec's fenced/indented
code block lowering and odf.js's Preformatted_Text paragraph reading still
carry only codeLanguage/styleId, with no verbatim-whitespace signal of
their own -- tracked in #1020 rather than left as a
silent mismatch between this comment and the readers that actually exist.
…, not just the first

writeRunRangeNodes located a footnote reference extent at each run index via
Array.prototype.find, which resolves at most one match -- so when two extents shared the
same startRun (two point-anchor references back-to-back, or two overlapping non-point
ranges), only the first was ever emitted and the second silently vanished with no
diagnostic. document-schema.js's own RunConstructExtentSchema comment states extents are
"data, not brackets" precisely because more than one can legitimately share a boundary
this way.

Every extent at an index is now collected: every point anchor (startRun === endRun) is
written as its own empty <a>, since wrapping zero runs never conflicts with a sibling
point anchor at the same boundary. At most one non-point range extent can actually claim
the runs from that index onward, so the first one in the input's own array order wins and
any other range extent found at the same index is reported through the diagnostic sink
(CONSTRUCT_UNREPRESENTED) as a genuine, unrepresentable overlap -- two sibling <a>
elements can't both claim the identical starting run without nesting one inside the
other, which HTML's interactive-content rule forbids. The underlying run text is never
lost either way, since it is still written by whichever extent wins, or by the ordinary
per-run path once the winner's own range ends.
…, not one <li> each

decomposeSection opens a fresh ListGroupNode for every list paragraph at a given level,
regardless of itemId, so a genuinely multi-block list item -- readList mints ONE itemId
per real <li> and shares it across every block readContainerChildren produces from that
<li>'s own children -- arrives at writeList as several separate, adjacent entries rather
than one entry with several blocks. writeList wrote one <li> per entry unconditionally,
so a <li> holding, say, a horizontal rule followed by a paragraph round-tripped back out
as two sibling <li> elements instead of the single item the source document actually had.

document-schema.js's own ContentListMembership.itemId field exists precisely to
distinguish "one item, several blocks" (same itemId) from "several items sharing a
numId/level" (different itemIds) -- a distinction the writer was silently not honouring.
writeList now re-groups a run of consecutive entries sharing the same defined itemId into
one <li>, writing each entry's own anchor content and nested children in turn inside it.
An itemId of undefined never groups with anything, including another undefined neighbour,
since absence means no item identity was carried at all.

A related gap this does not close is pinned as a regression test rather than fixed here:
when a footnote reference sits in a list item's last block with nothing plain in between
before that footnote's own body, document-schema.js's decomposeSection attaches the
footnote's construct group as a child of the still-open list item rather than at the
section root, so its <aside> nests inside the <li> instead of sitting beside the list.
That is a decomposeSection defect reachable from every codec built on it, tracked
separately in #1022 rather than patched around here.
…al trigger

The gotchas bullet read as if epub/image-pre-unsupported only fires for an image with no
alt text ("naming the loss when it carries none"), matching the sibling inline-image
bullet's own genuinely conditional wording. readPreImageFallbackText actually calls the
sink unconditionally, before it even looks at the alt attribute -- the diagnostic fires
for every image reached inside a <pre>, alt text or not. Reworded to state that plainly.
…ording

The CONSTRUCT_UNREPRESENTED message for a losing footnote extent claimed its
run text is "written unwrapped", but the writer never drops that text: every
run the losing extent covers is still emitted, wrapped inside the winning
extent's own anchor wherever the two ranges overlap, and unwrapped only past
the winner's own endRun. Reword the message to describe that actual
behaviour instead of a drop that never happens.
…ing run range

writeRunRangeNodes's own diagnostic only fired for two extents sharing the
exact same startRun. A winning range extent advances the walk straight from
its own startRun to its own endRun, skipping every index in between, so a
second extent whose startRun falls strictly INSIDE that range -- a genuinely
crossing range, or a point anchor nested in the interior -- was never
compared against the walk's own index at all and vanished with no
diagnostic. This is the identical silent-drop class the same-startRun fix
closed, reached via a different input shape that document-schema.js's own
RunConstructExtentSchema names explicitly ("two entries may cross freely").

Replace the inline collision check with a Set tracking every extent the walk
actually emits, then sweep the full candidate set once the walk completes
and report CONSTRUCT_UNREPRESENTED for anything never emitted, whatever the
reason. The same run text still survives the loss either way: a range
extent's own runs are still written, wrapped by whichever extent's range
actually claims them or unwrapped past it; a point anchor wraps none of its
own, so only its own link marker goes missing.
…ly scope

isFootnoteExtent silently excludes every other AnchorType a run-level
RunConstructExtent can carry -- ooxml.js's own docx reader emits bookmark
and comment extents at the identical run scope via pairRunRangeMarkers, and
this writer has no representation or diagnostic for either one. Filed as
#1025 rather than fixed here, since it is a distinct
construct kind never handled at all, not a bug in this function's own
footnote handling.
…n a comment

The comment citing ooxml.js's own bookmark/comment run-scope emitter named a
function ('pairRunRangeMarkers') that has never existed in this repository's
history. The real function it describes -- and the one already named
correctly twice elsewhere in ooxml.js's own source -- is
runRangeMarkerExtents (src/typed/docx/constructs.ts).
…g one absolute cause

The comment and both CONSTRUCT_UNREPRESENTED messages claimed a point
extent only ever goes unemitted by sitting inside another extent's own
winning run range, and a range extent only ever goes unemitted by
overlapping or colliding with another extent. Neither is the whole story:
an extent whose own startRun sits beyond the paragraph's actual run count,
or a range extent with endRun < startRun, falls through the identical
code path with no other extent involved at all, so the old wording
falsely blamed a specific conflicting extent that doesn't exist for
either shape. Reword the comment and both messages to state the cause
conditionally, covering the malformed-range case alongside the genuine
collision/overlap case.
@Mearman
Mearman force-pushed the fix/epub-codec-silent-content-drops branch from 8291a5d to a17cb0c Compare September 5, 2026 20:55
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.

epub-codec silently drops content in <pre>/<code>, a <table> <caption>, a <div>-wrapped <dl> group, and a directly-nested <ul>/<ol>, with no diagnostic

1 participant