Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 69 additions & 37 deletions src/elements/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { isElement, isTextNode } from '../utils';
import { transferContent, parseHTML, serializeHTML, isDangerousUrl } from '../utils/dom';
import { transferContent, isDangerousUrl } from '../utils/dom';
import { BLOCK_LEVEL_ELEMENTS } from '../constants';

// Pre-compile regular expressions
Expand Down Expand Up @@ -282,8 +282,7 @@ function createFigureWithCaption(imageElement: Element, captionElement: Element,

// Add caption
const figcaption = doc.createElement('figcaption');
const uniqueCaptionContent = extractUniqueCaptionContent(captionElement);
figcaption.appendChild(parseHTML(doc, uniqueCaptionContent));
appendUniqueCaptionContent(captionElement, figcaption, doc);
figure.appendChild(figcaption);

return figure;
Expand Down Expand Up @@ -595,46 +594,79 @@ function findCaption(element: Element): Element | null {
return null;
}

// Inline elements kept (as clean shells) when rebuilding a caption.
const CAPTION_INLINE_TAGS = new Set(['a', 'em', 'i', 'strong', 'b', 'code', 'sub', 'sup']);

/**
* Extract unique caption content to avoid duplication
* Copy a caption's content into `target`, skipping repeated text.
*
* Captions are often duplicated in nested elements (e.g. a visually hidden
* copy), so text nodes whose trimmed text was already seen are dropped.
* Links and inline formatting are kept: a caption like
* `see the source code <a href="…">here</a>.` must not lose its link.
* Other wrappers are unwrapped, with a space at block boundaries so that
* `<div>A</div><div>B</div>` doesn't become "AB".
*
* Builds DOM nodes rather than an HTML string, so text such as `&lt;img&gt;`
* stays text instead of being re-parsed into an element.
*/
function extractUniqueCaptionContent(caption: Element): string {
// Get all text nodes and elements with text content
const textNodes: string[] = [];
const processedTexts = new Set<string>();

// Helper function to process a node
const processNode = (node: Node) => {
if (isTextNode(node)) {
const text = node.textContent?.trim() || '';
if (text && !processedTexts.has(text)) {
textNodes.push(text);
processedTexts.add(text);
}
} else if (isElement(node)) {
// Process child nodes
const childNodes = node.childNodes;
for (let i = 0; i < childNodes.length; i++) {
processNode(childNodes[i]);
function appendUniqueCaptionContent(caption: Element, target: Element, doc: Document): void {
const seen = new Set<string>();

const copyChildren = (source: Node, parent: Node) => {
const childNodes = Array.from(source.childNodes);
for (const node of childNodes) {
if (isTextNode(node)) {
const text = node.textContent || '';
const trimmed = text.trim();
if (!trimmed) {
if (text) parent.appendChild(doc.createTextNode(' '));
continue;
}
if (seen.has(trimmed)) continue;
seen.add(trimmed);
parent.appendChild(doc.createTextNode(text));
} else if (isElement(node)) {
const tag = node.tagName.toLowerCase();
if (CAPTION_INLINE_TAGS.has(tag)) {
const shell = doc.createElement(tag);
if (tag === 'a') {
const href = node.getAttribute('href');
if (href && !isDangerousUrl(href, false)) shell.setAttribute('href', href);
}
copyChildren(node, shell);
if (shell.textContent?.trim()) parent.appendChild(shell);
} else if (tag === 'br') {
parent.appendChild(doc.createTextNode(' '));
} else {
const isBlock = BLOCK_LEVEL_ELEMENTS.has(tag);
if (isBlock) parent.appendChild(doc.createTextNode(' '));
copyChildren(node, parent);
if (isBlock) parent.appendChild(doc.createTextNode(' '));
}
}
}
};

// Process all child nodes
const childNodes = caption.childNodes;
for (let i = 0; i < childNodes.length; i++) {
processNode(childNodes[i]);
}

// If we found unique text nodes, use them
if (textNodes.length > 0) {
return textNodes.join(' ');

copyChildren(caption, target);

// Collapse the whitespace introduced by unwrapping
const walker = doc.createTreeWalker(target, 4 /* NodeFilter.SHOW_TEXT */);
const textNodes: Text[] = [];
while (walker.nextNode()) textNodes.push(walker.currentNode as Text);
textNodes.forEach((t, i) => {
let v = (t.textContent || '').replace(/\s+/g, ' ');
if (i === 0) v = v.replace(/^ /, '');
if (i === textNodes.length - 1) v = v.replace(/ $/, '');
t.textContent = v;
});
// Drop a space that ends up right before closing punctuation after a removed node
for (let i = 1; i < textNodes.length; i++) {
const prev = textNodes[i - 1];
if (/ $/.test(prev.textContent || '') && /^ /.test(textNodes[i].textContent || '')) {
textNodes[i].textContent = (textNodes[i].textContent || '').replace(/^ /, '');
}
}

// Otherwise, just use the inner HTML but try to clean it up
const html = serializeHTML(caption);

return html;
}

/**
Expand Down
40 changes: 40 additions & 0 deletions tests/expected/elements--figcaption-inline-formatting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
```json
{
"title": "Figcaption Inline Formatting",
"author": "",
"site": "example.com",
"published": ""
}
```

This article tests that links and inline formatting inside image captions survive figure standardization.

![A chart](https://www.example.com/images/chart.png)

Figure 1: A chart of the results. You can find the source code [here](https://www.example.com/code/notebook).

Paragraph of text between the images to separate content sections properly for the parser to work with.

![A diagram](https://www.example.com/images/diagram.png)

Figure 2: The *encoder* passes `hidden_state` to the [decoder](https://www.example.com/glossary/decoder).

More article text here with enough words for the content detection scoring algorithm to work properly.

![A photo](https://www.example.com/images/photo.jpg)

A photo of the venue. Photo credit: Example Studio

Another paragraph of text so that each figure is separated by regular body content in the article.

![Code sample](https://www.example.com/images/code.png)

Figure 4: Rendering \<img src=x> as text, not as an element.

One more paragraph of text so the last figure is also surrounded by regular body content.

![Warning](https://www.example.com/images/warning.png)

Figure 5: A dangerous link stays plain text.

Final paragraph of the article with some concluding text for the content parser.
4 changes: 2 additions & 2 deletions tests/expected/issues--227-noscript-lazy-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu

![First gallery image caption.](https://www.example.com/images/gallery-1.jpg)

First gallery image caption. Source One
First gallery image caption. [Source One](https://example.com/source1)

![Second gallery image caption.](https://www.example.com/images/gallery-2.jpg)

Source Two
[Source Two](https://example.com/source2)

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.

Expand Down
61 changes: 61 additions & 0 deletions tests/fixtures/elements--figcaption-inline-formatting.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<!-- {"url":"https://www.example.com/article/figcaption-inline-formatting"} -->
<html>
<head>
<title>Figcaption Inline Formatting</title>
</head>
<body>
<article>
<h1>Figcaption Inline Formatting</h1>
<p>This article tests that links and inline formatting inside image captions survive figure standardization.</p>

<!-- Case 1: Substack-style caption, text in spans with a link in between -->
<div class="captioned-image-container">
<figure>
<a class="image-link" href="https://www.example.com/images/chart.png">
<div class="image2-inset">
<picture>
<img src="https://www.example.com/images/chart.png" alt="A chart">
</picture>
</div>
</a>
<figcaption class="image-caption"><span>Figure 1: A chart of the results. You can find the source code </span><a href="https://www.example.com/code/notebook">here</a><span>.</span></figcaption>
</figure>
</div>

<p>Paragraph of text between the images to separate content sections properly for the parser to work with.</p>

<!-- Case 2: emphasis, inline code and a relative link -->
<figure>
<img src="https://www.example.com/images/diagram.png" alt="A diagram">
<figcaption>Figure 2: The <em>encoder</em> passes <code>hidden_state</code> to the <a href="/glossary/decoder">decoder</a>.</figcaption>
</figure>

<p>More article text here with enough words for the content detection scoring algorithm to work properly.</p>

<!-- Case 3: caption split across block elements keeps a space between them -->
<figure>
<img src="https://www.example.com/images/photo.jpg" alt="A photo">
<figcaption><div>A photo of the venue.</div><div>Photo credit: Example Studio</div></figcaption>
</figure>

<p>Another paragraph of text so that each figure is separated by regular body content in the article.</p>

<!-- Case 4: escaped markup in caption text stays text -->
<figure>
<img src="https://www.example.com/images/code.png" alt="Code sample">
<figcaption>Figure 4: Rendering &lt;img src=x&gt; as text, not as an element.</figcaption>
</figure>

<p>One more paragraph of text so the last figure is also surrounded by regular body content.</p>

<!-- Case 5: a javascript: link in a caption keeps its text but not its href -->
<figure>
<img src="https://www.example.com/images/warning.png" alt="Warning">
<figcaption>Figure 5: A <a href="javascript:alert(1)">dangerous link</a> stays plain text.</figcaption>
</figure>

<p>Final paragraph of the article with some concluding text for the content parser.</p>
</article>
</body>
</html>