From 6b0834158f1526aac868ddf5ee0d498612a83503 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 12 Aug 2026 14:53:40 +0200 Subject: [PATCH] fix: keep footnote labels on one line --- src/Parser/InlineParser.php | 6 +- .../FootnoteLabelIsSingleLineTest.php | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/TestCase/FootnoteLabelIsSingleLineTest.php diff --git a/src/Parser/InlineParser.php b/src/Parser/InlineParser.php index 28b48d2..04ffcdf 100644 --- a/src/Parser/InlineParser.php +++ b/src/Parser/InlineParser.php @@ -1957,8 +1957,10 @@ protected function applyConsecutiveAttributes(Node $node, string $text, int $sta */ protected function parseFootnoteRef(string $text, int $pos): ?array { - // Match [^label] - \G anchors at offset position, avoiding extra strpos check - if (!preg_match('/\G\[\^([^\]]+)\]/', $text, $matches, 0, $pos)) { + // A footnote label cannot cross a physical line. The definition marker + // is one line too, so accepting a newline here creates an identifier + // that no valid definition can bind (jgm/djot#208). + if (!preg_match('/\G\[\^([^\]\r\n]+)\]/', $text, $matches, 0, $pos)) { return null; } diff --git a/tests/TestCase/FootnoteLabelIsSingleLineTest.php b/tests/TestCase/FootnoteLabelIsSingleLineTest.php new file mode 100644 index 0000000..2d3f6a7 --- /dev/null +++ b/tests/TestCase/FootnoteLabelIsSingleLineTest.php @@ -0,0 +1,71 @@ + + */ + public static function lineEndings(): iterable + { + yield 'LF' => ["\n"]; + yield 'CRLF' => ["\r\n"]; + yield 'CR' => ["\r"]; + } + + #[DataProvider('lineEndings')] + public function testAReferenceLabelDoesNotCrossALineEnding(string $ending): void + { + $source = "before[^two{$ending}words].\n"; + $converter = new DjotConverter(warnings: true); + $document = $converter->parse($source); + $types = array_map( + static fn ($node): string => $node->getType(), + $document->getChildren()[0]->getChildren(), + ); + + self::assertNotContains('footnote_ref', $types); + self::assertContains('soft_break', $types); + self::assertStringNotContainsString('doc-noteref', $converter->convert($source)); + self::assertSame([], array_filter( + $converter->getWarnings(), + static fn ($warning): bool => str_contains($warning->getMessage(), 'Undefined footnote'), + )); + } + + public function testAMultilineDefinitionMarkerDoesNotRegisterOrSwallowText(): void + { + $source = "see[^two words].\n\n[^two\nwords]: note.\n"; + $converter = new DjotConverter(); + + $document = $converter->parse($source); + self::assertNotContains('footnote', array_map( + static fn ($node): string => $node->getType(), + $document->getChildren(), + )); + self::assertStringContainsString("[^two\nwords]: note.", $converter->convert($source)); + } + + /** + * @return iterable + */ + public static function sameLineLabels(): iterable + { + yield 'space' => ['two words']; + yield 'tab' => ["two\twords"]; + } + + #[DataProvider('sameLineLabels')] + public function testSameLineWhitespaceStillResolves(string $label): void + { + $html = (new DjotConverter())->convert("see[^{$label}].\n\n[^{$label}]: note.\n"); + self::assertStringContainsString('doc-noteref', $html); + } +}