Skip to content
Merged
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
20 changes: 20 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/org/jabref/htmltonode/FxRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -238,6 +239,17 @@ private TextFlow renderFlow(List<Inline> inlines, String baseClass, List<String>
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;
Expand Down
39 changes: 28 additions & 11 deletions src/main/java/org/jabref/htmltonode/HtmlRenderOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,73 +22,85 @@ 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,
String monospaceFontFamily,
Consumer<String> linkHandler,
@Nullable String baseUri,
boolean renderImages,
boolean loadRemoteImages) {
boolean loadRemoteImages,
boolean renderMath) {
this.baseFontSize = baseFontSize;
this.baseFontFamily = baseFontFamily;
this.monospaceFontFamily = monospaceFontFamily;
this.linkHandler = linkHandler;
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<String> 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
/// (replacement for the `<base href>` JabRef injects for WebView today).
///
/// @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 `<img>` 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 (`<code>`/`<pre>`) 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
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 15 additions & 2 deletions src/main/java/org/jabref/htmltonode/HtmlToNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,18 @@ public static List<Block> 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<Block> parse(String html, HtmlRenderOptions options) {
List<Block> 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
Expand All @@ -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)]
Expand All @@ -76,7 +89,7 @@ public static Region render(List<Block> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
148 changes: 148 additions & 0 deletions src/main/java/org/jabref/htmltonode/internal/MathRendering.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading