diff --git a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
index 03328fc..dd0c1a4 100644
--- a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
+++ b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
@@ -93,10 +93,7 @@ public static void configure(RichTextArea area, HtmlRenderOptions options) {
if (event.getButton() == MouseButton.PRIMARY && event.isStillSincePress()) {
TextPos position = area.getTextPosition(event.getScreenX(), event.getScreenY());
if (position != null) {
- // Node segments (embedded images, tables) carry no character attributes, so the
- // model returns null there — only text runs can hold an HREF.
- StyleAttributeMap attributes = area.getModel().getStyleAttributeMap(null, position);
- String href = attributes != null ? attributes.get(HREF) : null;
+ String href = hrefAt(area.getModel(), position);
if (href != null) {
options.linkHandler().accept(href);
}
@@ -105,6 +102,16 @@ public static void configure(RichTextArea area, HtmlRenderOptions options) {
});
}
+ /// Returns the link target at `position`, or `null` if there is none.
+ ///
+ /// Node segments (embedded images, tables) carry no character attributes, so the model
+ /// returns a null attribute map there — only text runs can hold an [#HREF]. Clicking an
+ /// image therefore lands on a null map, which this method tolerates.
+ public static @Nullable String hrefAt(StyledTextModel model, TextPos position) {
+ StyleAttributeMap attributes = model.getStyleAttributeMap(null, position);
+ return attributes != null ? attributes.get(HREF) : null;
+ }
+
private void appendBlocks(List blocks) {
for (Block block : blocks) {
switch (block) {
diff --git a/src/test/java/org/jabref/htmltonode/RichTextRendererTest.java b/src/test/java/org/jabref/htmltonode/RichTextRendererTest.java
index 8cc70a9..b75c79d 100644
--- a/src/test/java/org/jabref/htmltonode/RichTextRendererTest.java
+++ b/src/test/java/org/jabref/htmltonode/RichTextRendererTest.java
@@ -142,6 +142,27 @@ void blockImageBecomesEmbeddedParagraph() {
assertTrue(model.size() >= 2, "expected an embedded image paragraph before the text");
}
+ @Test
+ void hrefAtImagePositionIsNullAndDoesNotThrow() {
+ // Clicking a book-cover / inline image resolves to a node segment, for which the model
+ // returns a null attribute map. hrefAt must tolerate that (regression for an NPE that
+ // dereferenced getStyleAttributeMap(...) on image clicks); offset 1 is the image node.
+ StyledTextModel model = model("a
b");
+
+ assertEquals("a b", model.getPlainText(0));
+ assertNull(attributesAt(model, 0, 1), "image node segment carries no character attribute map");
+ assertNull(RichTextRenderer.hrefAt(model, TextPos.ofLeading(0, 1)));
+ }
+
+ @Test
+ void hrefAtLinkPositionReturnsTarget() {
+ StyledTextModel model = model("see 10.1/x");
+
+ assertEquals("https://doi.org/10.1/x", RichTextRenderer.hrefAt(model, TextPos.ofLeading(0, 5)));
+ assertNull(RichTextRenderer.hrefAt(model, TextPos.ofLeading(0, 1)));
+ }
+
@Test
void cslEntryRendersAsStyledCitation() {
StyledTextModel model = model("""