Write this, then read back what the browser actually built:
<p>before <figure>FIG</figure> after</p>
I parsed it in Chrome this morning. What comes back is not that:
<p>before </p><figure>FIG</figure> after<p></p>
Four things happened that nobody asked for. The paragraph closed before the figure. The figure became a sibling rather than a child. The word after is now a bare text node hanging directly off <body>, in no paragraph at all. And the </p> you wrote arrived too late to close anything, so it opened and closed a new empty one.
Here is the measurement, which you can run in any console:
const parse = (html) => {
const doc = new DOMParser().parseFromString('<!doctype html><body>' + html, 'text/html');
return { html: doc.body.innerHTML, paragraphs: doc.body.querySelectorAll('p').length };
};
parse('<p>before <figure>FIG</figure> after</p>');
// { html: '<p>before </p><figure>FIG</figure> after<p></p>', paragraphs: 2 }
parse('<p>before <span>SPAN</span> after</p>');
// { html: '<p>before <span>SPAN</span> after</p>', paragraphs: 1 }
div and ul behave exactly like figure. span does not, and the difference is the whole mechanism.
This is specified behaviour, not a quirk
The HTML Standard says a p element's end tag can be omitted
if the
pelement is immediately followed by anaddress,article,aside,blockquote,details,dialog,div,dl,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,hr,main,menu,nav,ol,p,pre,search,section,table, orulelement
"Can be omitted" is the author-facing half of that sentence, and it reads like a convenience. The parser-facing half is that the tag is omitted whether you wrote it or not. Because the standard requires it, this is not a rendering difference you can test your way around in another browser. I measured Chrome; any conformant parser is obliged to build the same tree.
What it actually costs
The orphaned text is the part that bites. Every rule you wrote for p stops applying to it: line-height, max-width, margin, font-size, the lot. Half your sentence inherits from body and the half above it does not, so the spacing changes partway through what the reader sees as one paragraph. The empty <p> adds a margin on top of that, and it is not in your source to find.
That is a strange defect to receive. It is visible, it is clearly a layout fault, and it is confined to one page and one place on it.
Where it comes from, since nobody types this
Three routes, and the author is not wrong in any of them:
- A template or component wrapping a body in
<p>, where that body can contain a figure, a table or a code block. - A markdown or CMS pipeline that renders an image, an embed or an attachment as a
<figure>and lets it land mid-paragraph. I hit this last week in a publishing pipeline, which refused the file rather than render it, and that refusal is the only reason I looked. - Rich text pasted into a field whose output gets wrapped in
<p>on the way out.
The part you cannot see from the source
Here is the uncomfortable bit, and the reason this is worth knowing rather than just fixing. The defect does not exist in your template, your markdown, or your CMS record. All three are exactly what you intended, and re-reading them proves nothing. It exists only in the tree the parser built.
So the report arrives as "the spacing looks wrong on that page", from somebody who cannot tell you which half of which paragraph, and you cannot reproduce it by reading the file that produced it. Opening the page yourself may not help either, because the condition is in the content: it needs the paragraph that happened to contain a figure, which is one article out of two hundred.
The only artefact that shows the fault is the DOM as it was built at the time, on the page the reader had open. That is never what gets sent.
A cheap assertion
Both of these should be zero on a content page, and neither is on a page with this defect:
// Point this at whatever wraps your rendered content, not at body:
// on most pages the prose lives inside <article> or <main>.
const root = document.querySelector('article, main') || document.body;
const orphans = [...root.childNodes]
.filter(n => n.nodeType === Node.TEXT_NODE && n.textContent.trim());
const empties = [...root.querySelectorAll('p')]
.filter(p => !p.textContent.trim() && !p.children.length);
console.log({ orphans: orphans.length, empties: empties.length });
Cheap enough to assert in whatever test already renders your templates, which turns a layout report nobody can reproduce into a failing build.
If you take one thing from this: a correct source file and a correct DOM are two different claims. The parser sits between them, and it is allowed to disagree with you.
Top comments (0)