From 895e46751b2d2cb7a9be14b308505ccec51cf9fe Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 28 Nov 2025 14:20:12 +0100 Subject: [PATCH] Fix duplicate IDs for multiple footnote references When the same footnote is referenced multiple times, generate unique HTML-compliant IDs with suffixes (fnref1, fnref1-2, fnref1-3, etc.) and multiple backlinks with numbered superscripts. Fixes jgm/djot#348 --- src/Renderer/HtmlRenderer.php | 52 ++++++++++++++++++++++++-- tests/TestCase/DjotConverterTest.php | 56 ++++++++++++++++++++++++++++ tests/official/footnotes.test | 6 +-- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 466ae8e..91bc7ec 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -942,17 +942,24 @@ protected function renderFootnotesSection(): string foreach ($renderedContents as $number => $content) { $html .= '
  • ' . "\n"; + // Find the label for this footnote number to get ref count + $label = array_search($number, $this->footnoteNumbers, true); + $refCount = $label !== false ? ($this->footnoteRefCounts[$label] ?? 1) : 1; + + // Generate backlinks - multiple if footnote referenced multiple times + $backlinks = $this->generateBacklinks($number, $refCount); + // Add backlink - if content ends with

    , insert before it // Otherwise add as separate paragraph if ($content !== '' && preg_match('/^(.*)(<\/p>\n?)$/s', $content, $matches)) { - $content = $matches[1] . '↩︎

    '; + $content = $matches[1] . $backlinks . '

    '; $html .= $content . "\n"; } else { // Content doesn't end with paragraph (e.g., code block or empty) if ($content !== '') { $html .= $content . "\n"; } - $html .= '

    ↩︎

    ' . "\n"; + $html .= '

    ' . $backlinks . '

    ' . "\n"; } $html .= '
  • ' . "\n"; @@ -964,6 +971,32 @@ protected function renderFootnotesSection(): string return $html; } + /** + * Generate backlink(s) for a footnote + * + * @param int $number Footnote number + * @param int $refCount Number of times footnote was referenced + */ + protected function generateBacklinks(int $number, int $refCount): string + { + if ($refCount <= 1) { + // Single reference - simple backlink + return '↩︎'; + } + + // Multiple references - generate numbered backlinks + $links = []; + for ($i = 1; $i <= $refCount; $i++) { + $refId = 'fnref' . $number; + if ($i > 1) { + $refId .= '-' . $i; + } + $links[] = '↩︎' . $i . ''; + } + + return implode(' ', $links); + } + protected function renderFootnoteRef(FootnoteRef $node): string { $label = $node->getLabel(); @@ -975,8 +1008,21 @@ protected function renderFootnoteRef(FootnoteRef $node): string } $number = $this->footnoteNumbers[$label]; + // Track reference count for this footnote to generate unique IDs + if (!isset($this->footnoteRefCounts[$label])) { + $this->footnoteRefCounts[$label] = 0; + } + $this->footnoteRefCounts[$label]++; + $refCount = $this->footnoteRefCounts[$label]; + + // Generate unique ID: fnref1 for first, fnref1-2 for second, etc. + $refId = 'fnref' . $number; + if ($refCount > 1) { + $refId .= '-' . $refCount; + } + // Format: 1 - return '' . $number . ''; + return '' . $number . ''; } protected function renderMath(Math $node): string diff --git a/tests/TestCase/DjotConverterTest.php b/tests/TestCase/DjotConverterTest.php index e534674..558e97d 100644 --- a/tests/TestCase/DjotConverterTest.php +++ b/tests/TestCase/DjotConverterTest.php @@ -2380,4 +2380,60 @@ public function testBackticksWithClosingFenceIsCodeBlock(): void $this->assertStringContainsString('
    ', $result);
             $this->assertStringContainsString('x = y + 3', $result);
         }
    +
    +    /**
    +     * Test that multiple references to the same footnote get unique IDs
    +     *
    +     * Fix for GitHub issue jgm/djot#348: Multiple calls to the same note
    +     * should generate unique HTML-compliant IDs with suffixes.
    +     */
    +    public function testMultipleFootnoteReferencesGetUniqueIds(): void
    +    {
    +        $djot = <<<'DJOT'
    +First ref[^note].
    +
    +Second ref[^note].
    +
    +Third ref[^note].
    +
    +[^note]: The footnote.
    +DJOT;
    +
    +        $result = $this->converter->convert($djot);
    +
    +        // Each reference should have a unique ID
    +        $this->assertStringContainsString('id="fnref1"', $result);
    +        $this->assertStringContainsString('id="fnref1-2"', $result);
    +        $this->assertStringContainsString('id="fnref1-3"', $result);
    +
    +        // All should link to the same footnote
    +        $this->assertSame(3, substr_count($result, 'href="#fn1"'));
    +
    +        // Footnote should have multiple backlinks
    +        $this->assertStringContainsString('href="#fnref1"', $result);
    +        $this->assertStringContainsString('href="#fnref1-2"', $result);
    +        $this->assertStringContainsString('href="#fnref1-3"', $result);
    +
    +        // Single footnote (no duplicates)
    +        $this->assertSame(1, substr_count($result, 'id="fn1"'));
    +    }
    +
    +    public function testSingleFootnoteReferenceNoSuffix(): void
    +    {
    +        $djot = <<<'DJOT'
    +Single ref[^note].
    +
    +[^note]: The footnote.
    +DJOT;
    +
    +        $result = $this->converter->convert($djot);
    +
    +        // Single reference - no suffix needed
    +        $this->assertStringContainsString('id="fnref1"', $result);
    +        $this->assertStringNotContainsString('fnref1-', $result);
    +
    +        // Simple backlink without numbering
    +        $this->assertStringContainsString('href="#fnref1"', $result);
    +        $this->assertStringNotContainsString('1 test1 and another2.

    -

    another ref to the first note1.

    +

    another ref to the first note1.


    1. This is a note.

      -

      Second paragraph.↩︎

      +

      Second paragraph.↩︎1 ↩︎2

    2. code
      @@ -85,7 +85,7 @@ text[^footnote].
       

      very long footnote2↩︎

    3. -

      bla bla2↩︎

      +

      bla bla2↩︎1 ↩︎2