diff --git a/build.gradle.kts b/build.gradle.kts index 91b1900..7b81fe6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,8 @@ import com.vanniktech.maven.publish.SourcesJar plugins { `java-library` id("com.vanniktech.maven.publish") version "0.37.0" + // Turns legacy non-modular jars (JLaTeXMath) into proper JPMS modules, matching JabRef's own build + id("org.gradlex.extra-java-module-info") version "1.14.2" } group = "org.jabref" @@ -41,6 +43,8 @@ val jfxPlatform = run { dependencies { api("org.jspecify:jspecify:1.0.0") implementation("org.jsoup:jsoup:1.22.2") + // TeX math rendering; GPL 2.0 with the linking ("Classpath") exception, like OpenJFX + implementation("org.scilab.forge:jlatexmath:1.0.7") compileOnly("org.openjfx:javafx-base:$javafxVersion:$jfxPlatform") compileOnly("org.openjfx:javafx-graphics:$javafxVersion:$jfxPlatform") @@ -60,6 +64,22 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +// JLaTeXMath 1.0.7 ships no module descriptor, and its optional Greek/Cyrillic fonts sit in +// companion resource-only jars, so Gradle leaves it on the classpath and `requires jlatexmath` +// fails to resolve. Promote it to a single proper module (fonts merged in so their .ttf resources +// resolve within the module) named `jlatexmath`, exporting its packages and reading the JDK +// modules jdeps reports it uses (java.desktop for Java2D, java.xml for its font-metric config). +extraJavaModuleInfo { + failOnMissingModuleInfo = false + module("org.scilab.forge:jlatexmath", "jlatexmath") { + requires("java.desktop") + requires("java.xml") + exportAllPackages() + mergeJar("org.scilab.forge:jlatexmath-font-greek") + mergeJar("org.scilab.forge:jlatexmath-font-cyrillic") + } +} + tasks.javadoc { // Document the exported API only; org.jabref.htmltonode.internal stays out of the docs. // The compiled classes are patched in so references to internal types still resolve. diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index ccb5284..95e6970 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -12,6 +12,10 @@ requires transitive jfx.incubator.richtext; + // TeX math rendering (HtmlRenderOptions#renderMath); JLaTeXMath rasterizes via Java2D + requires java.desktop; + requires jlatexmath; + exports org.jabref.htmltonode; exports org.jabref.htmltonode.model; exports org.jabref.htmltonode.rich; diff --git a/src/main/java/org/jabref/htmltonode/FxRenderer.java b/src/main/java/org/jabref/htmltonode/FxRenderer.java index 92a43bc..e3533b5 100644 --- a/src/main/java/org/jabref/htmltonode/FxRenderer.java +++ b/src/main/java/org/jabref/htmltonode/FxRenderer.java @@ -25,6 +25,7 @@ import javafx.scene.text.TextFlow; import org.jabref.htmltonode.internal.HighlightTextFlow; +import org.jabref.htmltonode.internal.MathRendering; import org.jabref.htmltonode.internal.RenderSupport; import org.jabref.htmltonode.internal.TextSelection; import org.jabref.htmltonode.model.Block; @@ -238,6 +239,17 @@ private TextFlow renderFlow(List inlines, String baseClass, List charIndex += 1; } } + case Inline.Math math -> { + Node mathNode = MathRendering.createMathNode(math, baseSize * math.style().fontScale() * scale); + if (mathNode != null) { + flow.getChildren().add(mathNode); + charIndex += 1; + } else { + Text fallback = createText(math.source(), math.style(), 1.0); + flow.getChildren().add(fallback); + charIndex += math.source().length(); + } + } } } return flow; diff --git a/src/main/java/org/jabref/htmltonode/HtmlRenderOptions.java b/src/main/java/org/jabref/htmltonode/HtmlRenderOptions.java index 0789cca..16e3d8f 100644 --- a/src/main/java/org/jabref/htmltonode/HtmlRenderOptions.java +++ b/src/main/java/org/jabref/htmltonode/HtmlRenderOptions.java @@ -22,6 +22,7 @@ public final class HtmlRenderOptions { private final @Nullable String baseUri; private final boolean renderImages; private final boolean loadRemoteImages; + private final boolean renderMath; private HtmlRenderOptions(double baseFontSize, @Nullable String baseFontFamily, @@ -29,7 +30,8 @@ private HtmlRenderOptions(double baseFontSize, Consumer linkHandler, @Nullable String baseUri, boolean renderImages, - boolean loadRemoteImages) { + boolean loadRemoteImages, + boolean renderMath) { this.baseFontSize = baseFontSize; this.baseFontFamily = baseFontFamily; this.monospaceFontFamily = monospaceFontFamily; @@ -37,35 +39,36 @@ private HtmlRenderOptions(double baseFontSize, this.baseUri = baseUri; this.renderImages = renderImages; this.loadRemoteImages = loadRemoteImages; + this.renderMath = renderMath; } /// Default options: system font and size, links ignored, `file:`/`data:` images rendered, - /// remote images not loaded, no base URI. + /// remote images not loaded, no base URI, no math rendering. /// /// @return the default options public static HtmlRenderOptions defaults() { return new HtmlRenderOptions(-1, null, "Monospaced", url -> { - }, null, true, false); + }, null, true, false, false); } /// Base font size in points/pixels; unset means [Font#getDefault()]'s size. public HtmlRenderOptions withBaseFontSize(double newBaseFontSize) { - return new HtmlRenderOptions(newBaseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, baseUri, renderImages, loadRemoteImages); + return new HtmlRenderOptions(newBaseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, baseUri, renderImages, loadRemoteImages, renderMath); } /// Font family for regular text; unset means [Font#getDefault()]'s family. public HtmlRenderOptions withBaseFontFamily(@Nullable String newBaseFontFamily) { - return new HtmlRenderOptions(baseFontSize, newBaseFontFamily, monospaceFontFamily, linkHandler, baseUri, renderImages, loadRemoteImages); + return new HtmlRenderOptions(baseFontSize, newBaseFontFamily, monospaceFontFamily, linkHandler, baseUri, renderImages, loadRemoteImages, renderMath); } /// Font family for `code`/`pre` content. Default: `"Monospaced"` (JavaFX logical font). public HtmlRenderOptions withMonospaceFontFamily(String newMonospaceFontFamily) { - return new HtmlRenderOptions(baseFontSize, baseFontFamily, newMonospaceFontFamily, linkHandler, baseUri, renderImages, loadRemoteImages); + return new HtmlRenderOptions(baseFontSize, baseFontFamily, newMonospaceFontFamily, linkHandler, baseUri, renderImages, loadRemoteImages, renderMath); } /// Invoked with the (resolved) `href` when a link is clicked. Default: no-op. public HtmlRenderOptions withLinkHandler(Consumer newLinkHandler) { - return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, newLinkHandler, baseUri, renderImages, loadRemoteImages); + return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, newLinkHandler, baseUri, renderImages, loadRemoteImages, renderMath); } /// Base URI against which relative `href`/`src` values are resolved @@ -73,22 +76,31 @@ public HtmlRenderOptions withLinkHandler(Consumer newLinkHandler) { /// /// @param newBaseUri the base URI; use [#withoutBaseUri()] to express absence public HtmlRenderOptions withBaseUri(String newBaseUri) { - return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, newBaseUri, renderImages, loadRemoteImages); + return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, newBaseUri, renderImages, loadRemoteImages, renderMath); } /// Leaves relative `href`/`src` values unresolved (the default). public HtmlRenderOptions withoutBaseUri() { - return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, null, renderImages, loadRemoteImages); + return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, null, renderImages, loadRemoteImages, renderMath); } /// Disable to skip `` entirely (useful in tests and tooltips). public HtmlRenderOptions withRenderImages(boolean newRenderImages) { - return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, baseUri, newRenderImages, loadRemoteImages); + return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, baseUri, newRenderImages, loadRemoteImages, renderMath); } /// Allow `http(s):` image sources. Off by default; `file:`/`data:`/`jar:` always load. public HtmlRenderOptions withLoadRemoteImages(boolean newLoadRemoteImages) { - return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, baseUri, renderImages, newLoadRemoteImages); + return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, baseUri, renderImages, newLoadRemoteImages, renderMath); + } + + /// Enable to recognize TeX math in text content (`$…$`, `$$…$$`, `\(…\)`, `\[…\]`) + /// and render it as equations (via JLaTeXMath). Off by default: a `$` is ordinary text in + /// most HTML, and recognition applies delimiter heuristics that only make sense for content + /// that is known to contain TeX (like BibTeX-sourced previews). Code (``/`
`) and
+    /// link text are never scanned.
+    public HtmlRenderOptions withRenderMath(boolean newRenderMath) {
+        return new HtmlRenderOptions(baseFontSize, baseFontFamily, monospaceFontFamily, linkHandler, baseUri, renderImages, loadRemoteImages, newRenderMath);
     }
 
     /// @return the configured base font size, or a non-positive value if unset
@@ -126,6 +138,11 @@ public boolean loadRemoteImages() {
         return loadRemoteImages;
     }
 
+    /// @return whether TeX math in text content is recognized and rendered as equations
+    public boolean renderMath() {
+        return renderMath;
+    }
+
     /// @return the effective base font size: the configured one, or [Font#getDefault()]'s size
     public double resolvedBaseFontSize() {
         return baseFontSize > 0 ? baseFontSize : Font.getDefault().getSize();
diff --git a/src/main/java/org/jabref/htmltonode/HtmlToNode.java b/src/main/java/org/jabref/htmltonode/HtmlToNode.java
index 2f3cb9e..c05f024 100644
--- a/src/main/java/org/jabref/htmltonode/HtmlToNode.java
+++ b/src/main/java/org/jabref/htmltonode/HtmlToNode.java
@@ -6,6 +6,7 @@
 import javafx.scene.layout.Region;
 
 import org.jabref.htmltonode.internal.HtmlToModel;
+import org.jabref.htmltonode.internal.MathSegmenter;
 import org.jabref.htmltonode.internal.PlainText;
 import org.jabref.htmltonode.model.Block;
 import org.jspecify.annotations.Nullable;
@@ -42,6 +43,18 @@ public static List parse(String html, @Nullable String baseUri) {
         return HtmlToModel.parse(html, baseUri);
     }
 
+    /// Parses HTML, applying the parse-affecting options: relative URLs are resolved against
+    /// [HtmlRenderOptions#baseUri()], and with [HtmlRenderOptions#renderMath()] TeX math spans
+    /// are recognized in text content (as [org.jabref.htmltonode.model.Inline.Math]).
+    ///
+    /// @param html    the HTML to parse
+    /// @param options the options; also pass them to the renderer
+    /// @return the parsed blocks, in document order; empty for blank input, never `null`
+    public static List parse(String html, HtmlRenderOptions options) {
+        List blocks = HtmlToModel.parse(html, options.baseUri());
+        return options.renderMath() ? MathSegmenter.apply(blocks) : blocks;
+    }
+
     /// Parses and renders HTML with [HtmlRenderOptions#defaults()].
     ///
     /// @param html the HTML to render
@@ -57,7 +70,7 @@ public static Region render(String html) {
     /// @param options rendering options
     /// @return the rendered content, ready to be placed in a `ScrollPane` or any other parent
     public static Region render(String html, HtmlRenderOptions options) {
-        return FxRenderer.render(parse(html, options.baseUri()), options);
+        return FxRenderer.render(parse(html, options), options);
     }
 
     /// Renders an already parsed block model, e.g. to render the result of [#parse(String)]
@@ -76,7 +89,7 @@ public static Region render(List blocks, HtmlRenderOptions options) {
     /// @param html the HTML to convert
     /// @return the text content with paragraph breaks, list markers and tab-separated table cells
     public static String toPlainText(String html) {
-        return PlainText.of(parse(html, null));
+        return PlainText.of(parse(html));
     }
 
     /// Extracts readable plain text from an already parsed block model.
diff --git a/src/main/java/org/jabref/htmltonode/internal/BlockCollector.java b/src/main/java/org/jabref/htmltonode/internal/BlockCollector.java
index 65782b6..6d7833f 100644
--- a/src/main/java/org/jabref/htmltonode/internal/BlockCollector.java
+++ b/src/main/java/org/jabref/htmltonode/internal/BlockCollector.java
@@ -86,6 +86,7 @@ private void closeParagraph() {
             boolean hasContent = inlines.stream().anyMatch(inline -> switch (inline) {
                 case Inline.TextRun(String text, InlineStyle ignored) -> !text.isBlank();
                 case Inline.Image ignored -> true;
+                case Inline.Math ignored -> true;
                 case Inline.LineBreak ignored -> false;
             });
             if (!hasContent) {
diff --git a/src/main/java/org/jabref/htmltonode/internal/MathRendering.java b/src/main/java/org/jabref/htmltonode/internal/MathRendering.java
new file mode 100644
index 0000000..d24e8d2
--- /dev/null
+++ b/src/main/java/org/jabref/htmltonode/internal/MathRendering.java
@@ -0,0 +1,148 @@
+package org.jabref.htmltonode.internal;
+
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+
+import javafx.scene.image.ImageView;
+import javafx.scene.image.PixelFormat;
+import javafx.scene.image.WritableImage;
+import javafx.scene.layout.Region;
+import javafx.scene.paint.Color;
+import javafx.scene.paint.Paint;
+import javafx.scene.text.Text;
+
+import org.jabref.htmltonode.model.Inline;
+
+import org.jspecify.annotations.Nullable;
+import org.scilab.forge.jlatexmath.TeXConstants;
+import org.scilab.forge.jlatexmath.TeXFormula;
+import org.scilab.forge.jlatexmath.TeXIcon;
+
+/// Renders [Inline.Math] via JLaTeXMath into an image-backed node, baseline-aligned with the
+/// surrounding text.
+public final class MathRendering {
+
+    /// JLaTeXMath rasterizes through Java2D whose antialiasing is coarser than JavaFX text
+    /// rendering; drawing at double size and scaling down matches the surrounding glyph quality
+    /// (and stays crisp on HiDPI output scales up to 2).
+    private static final double SUPERSAMPLE = 2.0;
+
+    private MathRendering() {
+    }
+
+    /// @return whether the TeX source parses — callers render [Inline.Math#source()] as plain
+    /// text otherwise. Does not require the JavaFX toolkit.
+    public static boolean isRenderable(Inline.Math math) {
+        try {
+            new TeXFormula(math.tex());
+            return true;
+        } catch (RuntimeException | LinkageError e) {
+            return false;
+        }
+    }
+
+    /// Creates the node for a math span, or `null` when the TeX does not parse or the image
+    /// subsystem is unavailable (fully headless tests). The equation follows the color of the
+    /// surrounding text: an explicit run color wins, otherwise the color is looked up via CSS
+    /// (style class `html-text`) so themes restyle equations like any other text.
+    ///
+    /// @param math      the math span
+    /// @param fontSize  the effective font size of the surrounding text in pixels
+    /// @return the baseline-aligned node with style class `html-math`, or `null`
+    public static @Nullable Region createMathNode(Inline.Math math, double fontSize) {
+        try {
+            TeXFormula formula = new TeXFormula(math.tex());
+            TeXIcon icon = formula.createTeXIcon(
+                    math.display() ? TeXConstants.STYLE_DISPLAY : TeXConstants.STYLE_TEXT,
+                    (float) (fontSize * SUPERSAMPLE));
+            if ((icon.getIconWidth() <= 0) || (icon.getIconHeight() <= 0)) {
+                return null;
+            }
+            String explicitColor = math.style().color();
+            Color fixedColor = explicitColor == null
+                    ? null
+                    : RenderSupport.parseColor(explicitColor).orElse(null);
+            MathNode node = new MathNode(icon, fixedColor);
+            node.getStyleClass().add("html-math");
+            return node;
+        } catch (RuntimeException | LinkageError e) {
+            // invalid TeX, or graphics unavailable: caller falls back to the source text
+            return null;
+        }
+    }
+
+    /// The rendered equation. The image is repainted whenever the effective text color changes,
+    /// tracked through an invisible [Text] probe carrying the `html-text` style class — the same
+    /// CSS that colors the real text runs.
+    private static final class MathNode extends Region {
+
+        private final TeXIcon icon;
+        private final ImageView view = new ImageView();
+        private final Text colorProbe = new Text();
+        private final double width;
+        private final double height;
+        private final double baseline;
+
+        private MathNode(TeXIcon icon, @Nullable Color fixedColor) {
+            this.icon = icon;
+            this.width = icon.getIconWidth() / SUPERSAMPLE;
+            this.height = icon.getIconHeight() / SUPERSAMPLE;
+            this.baseline = (icon.getIconHeight() - icon.getIconDepth()) / SUPERSAMPLE;
+
+            view.setFitWidth(width);
+            view.setFitHeight(height);
+            view.setSmooth(true);
+            view.setManaged(false);
+            getChildren().add(view);
+
+            setMinSize(width, height);
+            setPrefSize(width, height);
+            setMaxSize(width, height);
+
+            if (fixedColor != null) {
+                repaint(fixedColor);
+            } else {
+                colorProbe.getStyleClass().add("html-text");
+                colorProbe.setManaged(false);
+                colorProbe.setVisible(false);
+                getChildren().add(colorProbe);
+                colorProbe.fillProperty().addListener((observable, oldFill, newFill) -> repaint(newFill));
+                repaint(colorProbe.getFill());
+            }
+        }
+
+        private void repaint(Paint fill) {
+            Color color = fill instanceof Color c ? c : Color.BLACK;
+            icon.setForeground(new java.awt.Color(
+                    (float) color.getRed(),
+                    (float) color.getGreen(),
+                    (float) color.getBlue(),
+                    (float) color.getOpacity()));
+            int imageWidth = icon.getIconWidth();
+            int imageHeight = icon.getIconHeight();
+            BufferedImage buffer = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB);
+            Graphics2D graphics = buffer.createGraphics();
+            try {
+                graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+                icon.paintIcon(null, graphics, 0, 0);
+            } finally {
+                graphics.dispose();
+            }
+            int[] pixels = buffer.getRGB(0, 0, imageWidth, imageHeight, null, 0, imageWidth);
+            WritableImage image = new WritableImage(imageWidth, imageHeight);
+            image.getPixelWriter().setPixels(0, 0, imageWidth, imageHeight, PixelFormat.getIntArgbInstance(), pixels, 0, imageWidth);
+            view.setImage(image);
+        }
+
+        @Override
+        protected void layoutChildren() {
+            view.resizeRelocate(0, 0, width, height);
+        }
+
+        @Override
+        public double getBaselineOffset() {
+            return baseline;
+        }
+    }
+}
diff --git a/src/main/java/org/jabref/htmltonode/internal/MathSegmenter.java b/src/main/java/org/jabref/htmltonode/internal/MathSegmenter.java
new file mode 100644
index 0000000..21c00e4
--- /dev/null
+++ b/src/main/java/org/jabref/htmltonode/internal/MathSegmenter.java
@@ -0,0 +1,164 @@
+package org.jabref.htmltonode.internal;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jabref.htmltonode.model.Block;
+import org.jabref.htmltonode.model.Inline;
+import org.jabref.htmltonode.model.InlineStyle;
+
+/// Splits TeX math spans out of the parsed model's text runs, turning them into [Inline.Math].
+///
+/// Recognized delimiters: `$…$` and `\(…\)` (inline style), `$$…$$` and `\[…\]` (display style).
+/// Because a `$` is ordinary text outside TeX-sourced content, single-dollar spans follow
+/// Pandoc's heuristics: the opening `$` must be followed and the closing `$` preceded by
+/// non-whitespace, and the closing `$` must not be followed by a digit — so "costs $5 and $10"
+/// stays text. `\$` never opens or closes a span. Code runs (monospace), link text, and `
`
+/// blocks are never scanned; a span cannot cross an element boundary (it must lie within one
+/// styled run).
+public final class MathSegmenter {
+
+    private MathSegmenter() {
+    }
+
+    public static List apply(List blocks) {
+        return blocks.stream().map(MathSegmenter::applyBlock).toList();
+    }
+
+    private static Block applyBlock(Block block) {
+        return switch (block) {
+            case Block.Paragraph(List inlines, boolean spaced, List cssClasses) ->
+                    new Block.Paragraph(segmentInlines(inlines), spaced, cssClasses);
+            case Block.Heading(int level, List inlines, List cssClasses) ->
+                    new Block.Heading(level, segmentInlines(inlines), cssClasses);
+            case Block.ListBlock(boolean ordered, int start, List items) ->
+                    new Block.ListBlock(ordered, start,
+                            items.stream().map(item -> new Block.ListItem(apply(item.blocks()))).toList());
+            case Block.DefinitionList(List items) ->
+                    new Block.DefinitionList(items.stream()
+                                                  .map(item -> new Block.DefinitionItem(item.term(), apply(item.blocks())))
+                                                  .toList());
+            case Block.Quote(List children) -> new Block.Quote(apply(children));
+            case Block.Table(List rows) ->
+                    new Block.Table(rows.stream()
+                                        .map(row -> new Block.TableRow(row.cells().stream()
+                                                                          .map(cell -> new Block.TableCell(cell.header(), cell.columnSpan(), apply(cell.blocks())))
+                                                                          .toList()))
+                                        .toList());
+            case Block.Container(List children, List cssClasses) ->
+                    new Block.Container(apply(children), cssClasses);
+            // code content: a $ there is code, not math
+            case Block.Pre pre -> pre;
+            case Block.Rule rule -> rule;
+        };
+    }
+
+    private static List segmentInlines(List inlines) {
+        List result = new ArrayList<>(inlines.size());
+        for (Inline inline : inlines) {
+            if (inline instanceof Inline.TextRun(String text, InlineStyle style)
+                    && !style.monospace()
+                    && !style.link()) {
+                result.addAll(segmentRun(text, style));
+            } else {
+                result.add(inline);
+            }
+        }
+        return result;
+    }
+
+    private static List segmentRun(String text, InlineStyle style) {
+        List result = new ArrayList<>(1);
+        StringBuilder plain = new StringBuilder();
+        int i = 0;
+        while (i < text.length()) {
+            char c = text.charAt(i);
+            if ((c == '\\') && (i + 1 < text.length())) {
+                char next = text.charAt(i + 1);
+                if (next == '$') {
+                    // \$ is an escaped literal dollar: never a delimiter, and the backslash is
+                    // dropped so the rendered text shows "$" rather than "\$"
+                    plain.append('$');
+                    i += 2;
+                    continue;
+                }
+                if ((next == '(') || (next == '[')) {
+                    String closer = (next == '(') ? "\\)" : "\\]";
+                    int end = text.indexOf(closer, i + 2);
+                    String tex = (end < 0) ? "" : text.substring(i + 2, end);
+                    if (!tex.isBlank()) {
+                        emit(result, plain, tex, next == '[', text.substring(i, end + 2), style);
+                        i = end + 2;
+                        continue;
+                    }
+                }
+            }
+            if (c == '$') {
+                int end = (i + 1 < text.length()) && (text.charAt(i + 1) == '$')
+                        ? closingDoubleDollar(text, i + 2)
+                        : closingSingleDollar(text, i + 1);
+                if (end >= 0) {
+                    boolean display = text.charAt(i + 1) == '$';
+                    int texStart = display ? i + 2 : i + 1;
+                    int spanEnd = display ? end + 2 : end + 1;
+                    emit(result, plain, text.substring(texStart, end), display, text.substring(i, spanEnd), style);
+                    i = spanEnd;
+                    continue;
+                }
+            }
+            plain.append(c);
+            i++;
+        }
+        if (!plain.isEmpty()) {
+            result.add(new Inline.TextRun(plain.toString(), style));
+        }
+        return result;
+    }
+
+    private static void emit(List result, StringBuilder plain, String tex, boolean display, String source, InlineStyle style) {
+        if (!plain.isEmpty()) {
+            result.add(new Inline.TextRun(plain.toString(), style));
+            plain.setLength(0);
+        }
+        result.add(new Inline.Math(tex, display, source, style));
+    }
+
+    /// @return the index of the `$$` closing a span whose content starts at `from`, or -1
+    private static int closingDoubleDollar(String text, int from) {
+        int end = from;
+        while ((end = text.indexOf("$$", end)) >= 0) {
+            if (!escaped(text, end)) {
+                return text.substring(from, end).isBlank() ? -1 : end;
+            }
+            end++;
+        }
+        return -1;
+    }
+
+    /// @return the index of the `$` closing a span whose content starts at `from`, or -1
+    private static int closingSingleDollar(String text, int from) {
+        if ((from >= text.length()) || Character.isWhitespace(text.charAt(from)) || (text.charAt(from) == '$')) {
+            // opening $ must be followed by non-space content
+            return -1;
+        }
+        for (int end = from + 1; end < text.length(); end++) {
+            if ((text.charAt(end) != '$') || escaped(text, end)) {
+                continue;
+            }
+            if (Character.isWhitespace(text.charAt(end - 1))) {
+                // closing $ must be preceded by non-space ("x$ and $y" is not a span)
+                continue;
+            }
+            if ((end + 1 < text.length()) && Character.isDigit(text.charAt(end + 1))) {
+                // "$5 or $10" — a digit right after the closing $ means currency, not math
+                return -1;
+            }
+            return end;
+        }
+        return -1;
+    }
+
+    private static boolean escaped(String text, int index) {
+        return (index > 0) && (text.charAt(index - 1) == '\\');
+    }
+}
diff --git a/src/main/java/org/jabref/htmltonode/internal/PlainText.java b/src/main/java/org/jabref/htmltonode/internal/PlainText.java
index 0f501a3..4e95e1f 100644
--- a/src/main/java/org/jabref/htmltonode/internal/PlainText.java
+++ b/src/main/java/org/jabref/htmltonode/internal/PlainText.java
@@ -92,6 +92,8 @@ private static void appendInlines(StringBuilder builder, List inlines) {
                         builder.append(alt);
                     }
                 }
+                // the original TeX including delimiters — plain-text copy round-trips
+                case Inline.Math math -> builder.append(math.source());
             }
         }
     }
diff --git a/src/main/java/org/jabref/htmltonode/model/Inline.java b/src/main/java/org/jabref/htmltonode/model/Inline.java
index de2b575..028d460 100644
--- a/src/main/java/org/jabref/htmltonode/model/Inline.java
+++ b/src/main/java/org/jabref/htmltonode/model/Inline.java
@@ -23,4 +23,15 @@ record LineBreak() implements Inline {
     /// @param alt        alternative text, or `null`
     record Image(String source, @Nullable CssLength width, @Nullable CssLength height, boolean blockImage, @Nullable String alt) implements Inline {
     }
+
+    /// A TeX math span recognized in text content (only when
+    /// [org.jabref.htmltonode.HtmlRenderOptions#renderMath()] is enabled).
+    ///
+    /// @param tex     the TeX source between the delimiters, e.g. `\frac{1}{2}`
+    /// @param display display style (`$$…$$`, `\[…\]`) versus inline style (`$…$`, `\(…\)`)
+    /// @param source  the original text including delimiters — used for plain-text
+    ///                extraction and as fallback when the TeX cannot be rendered
+    /// @param style   the style of the surrounding run (font scale and explicit color apply)
+    record Math(String tex, boolean display, String source, InlineStyle style) implements Inline {
+    }
 }
diff --git a/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java b/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
index 951906d..241b4e9 100644
--- a/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
+++ b/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
@@ -135,7 +135,7 @@ public final String toPlainText() {
 
     private void rerender() {
         HtmlRenderOptions renderOptions = getOptions();
-        area.setModel(RichTextRenderer.buildModel(HtmlToNode.parse(getHtml(), renderOptions.baseUri()), renderOptions));
+        area.setModel(RichTextRenderer.buildModel(HtmlToNode.parse(getHtml(), renderOptions), renderOptions));
         RichTextRenderer.configure(area, renderOptions);
     }
 }
diff --git a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
index 08cb91b..9cac404 100644
--- a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
+++ b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
@@ -2,12 +2,15 @@
 
 import java.util.List;
 
+import javafx.scene.Node;
 import javafx.scene.input.MouseButton;
 import javafx.scene.layout.Region;
 import javafx.scene.paint.Color;
+import javafx.scene.text.Text;
 
 import org.jabref.htmltonode.FxRenderer;
 import org.jabref.htmltonode.HtmlRenderOptions;
+import org.jabref.htmltonode.internal.MathRendering;
 import org.jabref.htmltonode.internal.RenderSupport;
 import org.jabref.htmltonode.model.Block;
 import org.jabref.htmltonode.model.Inline;
@@ -90,7 +93,10 @@ 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) {
-                    String href = area.getModel().getStyleAttributeMap(null, position).get(HREF);
+                    // 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;
                     if (href != null) {
                         options.linkHandler().accept(href);
                     }
@@ -180,6 +186,20 @@ private void paragraph(List inlines, double scale, int minWeight, double
                         paragraphOccupied = true;
                     }
                 }
+                case Inline.Math math -> {
+                    if (MathRendering.isRenderable(math)) {
+                        double fontSize = baseSize * math.style().fontScale() * scale;
+                        // the node factory runs on the FX thread when the paragraph is shown;
+                        // if the image subsystem is unavailable there, show the TeX source
+                        model.addNodeSegment(() -> {
+                            Node node = MathRendering.createMathNode(math, fontSize);
+                            return node != null ? node : new Text(math.source());
+                        });
+                        paragraphOccupied = true;
+                    } else {
+                        appendRun(math.source(), math.style());
+                    }
+                }
             }
         }
     }
diff --git a/src/test/java/org/jabref/htmltonode/HtmlToModelTest.java b/src/test/java/org/jabref/htmltonode/HtmlToModelTest.java
index f251e1e..bf38aea 100644
--- a/src/test/java/org/jabref/htmltonode/HtmlToModelTest.java
+++ b/src/test/java/org/jabref/htmltonode/HtmlToModelTest.java
@@ -110,7 +110,7 @@ void linksResolveAgainstBaseUri() {
     @Test
     void baseHrefInsideDocumentWins() {
         List blocks = HtmlToNode.parse(
-                "x", null);
+                "x", (String) null);
         Block.Paragraph paragraph = assertInstanceOf(Block.Paragraph.class, blocks.getFirst());
         assertEquals("file:/data/dir/x.pdf", run(paragraph, 0).style().href());
     }
diff --git a/src/test/java/org/jabref/htmltonode/MathRenderingTest.java b/src/test/java/org/jabref/htmltonode/MathRenderingTest.java
new file mode 100644
index 0000000..0d3095a
--- /dev/null
+++ b/src/test/java/org/jabref/htmltonode/MathRenderingTest.java
@@ -0,0 +1,99 @@
+package org.jabref.htmltonode;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javafx.application.Platform;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.scene.layout.Region;
+import javafx.scene.text.Text;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// End-to-end rendering of math spans; needs the JavaFX toolkit (JLaTeXMath rasterizes into a
+/// [javafx.scene.image.WritableImage]). Run via `./gradlew guiTest` with a display or `xvfb-run`.
+@Tag("gui")
+class MathRenderingTest {
+
+    @BeforeAll
+    static void startToolkit() throws InterruptedException {
+        CountDownLatch started = new CountDownLatch(1);
+        try {
+            Platform.startup(started::countDown);
+        } catch (IllegalStateException alreadyRunning) {
+            started.countDown();
+        }
+        assertTrue(started.await(15, TimeUnit.SECONDS), "JavaFX toolkit failed to start");
+    }
+
+    @Test
+    void validMathRendersAsAnEquationNode() throws Exception {
+        Region root = renderOnFxThread("

mass $E=mc^2$ energy

"); + assertTrue(hasMathNode(root), "expected an html-math node for $E=mc^2$"); + } + + @Test + void unparseableMathFallsBackToSourceText() throws Exception { + Region root = renderOnFxThread("

see $\\nosuchcommand$ here

"); + assertFalse(hasMathNode(root), "broken TeX should not produce a math node"); + assertTrue(collectText(root).contains("$\\nosuchcommand$"), + "broken TeX should fall back to its verbatim source"); + } + + private static Region renderOnFxThread(String html) throws Exception { + AtomicReference result = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + Platform.runLater(() -> { + try { + Region root = HtmlToNode.render(html, HtmlRenderOptions.defaults().withRenderMath(true)); + new Scene(root, 400, 200); + root.applyCss(); + root.layout(); + result.set(root); + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + assertTrue(done.await(15, TimeUnit.SECONDS), "FX task timed out"); + if (error.get() != null) { + throw new AssertionError("FX render failed", error.get()); + } + return result.get(); + } + + private static boolean hasMathNode(Node node) { + if (node.getStyleClass().contains("html-math")) { + return true; + } + return node instanceof Parent parent + && parent.getChildrenUnmodifiable().stream().anyMatch(MathRenderingTest::hasMathNode); + } + + private static String collectText(Node node) { + List parts = new ArrayList<>(); + collectText(node, parts); + return String.join("", parts); + } + + private static void collectText(Node node, List parts) { + if (node instanceof Text text) { + parts.add(text.getText()); + } + if (node instanceof Parent parent) { + parent.getChildrenUnmodifiable().forEach(child -> collectText(child, parts)); + } + } +} diff --git a/src/test/java/org/jabref/htmltonode/MathSegmenterTest.java b/src/test/java/org/jabref/htmltonode/MathSegmenterTest.java new file mode 100644 index 0000000..4f96bc5 --- /dev/null +++ b/src/test/java/org/jabref/htmltonode/MathSegmenterTest.java @@ -0,0 +1,132 @@ +package org.jabref.htmltonode; + +import java.util.List; + +import org.jabref.htmltonode.internal.MathRendering; +import org.jabref.htmltonode.model.Block; +import org.jabref.htmltonode.model.Inline; +import org.jabref.htmltonode.model.InlineStyle; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Covers TeX-span recognition ([org.jabref.htmltonode.internal.MathSegmenter], reached through +/// [HtmlToNode#parse(String, HtmlRenderOptions)] with math enabled) and the toolkit-free half of +/// [MathRendering]. Rendering the equation into a node needs the JavaFX toolkit and lives in the +/// `gui`-tagged tests. +class MathSegmenterTest { + + private static List mathInlines(String html) { + List blocks = HtmlToNode.parse(html, HtmlRenderOptions.defaults().withRenderMath(true)); + assertEquals(1, blocks.size(), () -> "expected one block, got: " + blocks); + return assertInstanceOf(Block.Paragraph.class, blocks.getFirst()).inlines(); + } + + private static Inline.Math math(List inlines, int index) { + return assertInstanceOf(Inline.Math.class, inlines.get(index)); + } + + private static Inline.TextRun text(List inlines, int index) { + return assertInstanceOf(Inline.TextRun.class, inlines.get(index)); + } + + @Test + void inlineDollarSpanIsSplitOutWithSurroundingText() { + List inlines = mathInlines("mass-energy $E=mc^2$ here"); + assertEquals(3, inlines.size()); + assertEquals("mass-energy ", text(inlines, 0).text()); + Inline.Math math = math(inlines, 1); + assertEquals("E=mc^2", math.tex()); + assertFalse(math.display()); + assertEquals("$E=mc^2$", math.source()); + assertEquals(" here", text(inlines, 2).text()); + } + + @Test + void doubleDollarIsDisplayStyle() { + Inline.Math math = math(mathInlines("before $$a+b$$ after"), 1); + assertEquals("a+b", math.tex()); + assertTrue(math.display()); + assertEquals("$$a+b$$", math.source()); + } + + @Test + void parenDelimitersAreInlineStyle() { + Inline.Math math = math(mathInlines("x \\(a+b\\) y"), 1); + assertEquals("a+b", math.tex()); + assertFalse(math.display()); + assertEquals("\\(a+b\\)", math.source()); + } + + @Test + void bracketDelimitersAreDisplayStyle() { + Inline.Math math = math(mathInlines("x \\[a+b\\] y"), 1); + assertEquals("a+b", math.tex()); + assertTrue(math.display()); + assertEquals("\\[a+b\\]", math.source()); + } + + @Test + void mathIsNotRecognizedWithoutTheOption() { + List blocks = HtmlToNode.parse("$E=mc^2$"); + List inlines = assertInstanceOf(Block.Paragraph.class, blocks.getFirst()).inlines(); + assertNoMath(inlines); + assertEquals("$E=mc^2$", text(inlines, 0).text()); + } + + @Test + void currencyAmountsStayText() { + // Pandoc heuristic: a digit right after the closing $ marks currency, not a closing delimiter + assertNoMath(mathInlines("it costs $5 and $10 today")); + } + + @Test + void dollarAdjacentToWhitespaceIsNotADelimiter() { + // opening $ must be followed, and the closing $ preceded, by non-whitespace + assertNoMath(mathInlines("a $ x$ b")); + } + + @Test + void escapedDollarDoesNotOpenASpanAndDropsTheBackslash() { + List inlines = mathInlines("price \\$5 up to \\$9 each"); + assertNoMath(inlines); + assertEquals(1, inlines.size()); + assertEquals("price $5 up to $9 each", text(inlines, 0).text()); + } + + @Test + void mathInCodeIsLeftAlone() { + List inlines = mathInlines("$x$"); + assertNoMath(inlines); + assertTrue(text(inlines, 0).style().monospace()); + } + + @Test + void mathInLinkTextIsLeftAlone() { + List inlines = mathInlines("$y$"); + assertNoMath(inlines); + assertTrue(text(inlines, 0).style().link()); + } + + @Test + void plainTextExtractionRoundTripsTheSource() { + List blocks = HtmlToNode.parse("a $x+y$ b", HtmlRenderOptions.defaults().withRenderMath(true)); + assertEquals("a $x+y$ b", HtmlToNode.toPlainText(blocks).strip()); + } + + @Test + void isRenderableAcceptsValidTexAndRejectsBrokenTex() { + assertTrue(MathRendering.isRenderable(new Inline.Math("\\frac{1}{2}", false, "$\\frac{1}{2}$", InlineStyle.DEFAULT))); + // an unknown command makes JLaTeXMath throw during parsing; the renderer then falls back to source + assertFalse(MathRendering.isRenderable(new Inline.Math("\\nosuchcommand", false, "$\\nosuchcommand$", InlineStyle.DEFAULT))); + } + + private static void assertNoMath(List inlines) { + assertTrue(inlines.stream().noneMatch(inline -> inline instanceof Inline.Math), + () -> "expected no math span, got: " + inlines); + } +}