StringBounderEmbeddedFont.java
/* ========================================================================
* PlantUML : a free UML diagram generator
* ========================================================================
*
* (C) Copyright 2009-2025, Arnaud Roques
*
* Project Info: https://plantuml.com
*
* If you like this project or if you find it useful, you can support us at:
*
* https://plantuml.com/patreon (only 1$ per month!)
* https://plantuml.com/paypal
*
* This file is part of PlantUML.
*
* PlantUML is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PlantUML distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
* License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
* USA.
*
*
* Original Author: Arnaud Roques
*
*/
package net.sourceforge.plantuml.klimt.drawing.font;
import net.sourceforge.plantuml.FileFormat;
import net.sourceforge.plantuml.StringUtils;
import net.sourceforge.plantuml.klimt.font.StringBounder;
import net.sourceforge.plantuml.klimt.font.UFont;
import net.sourceforge.plantuml.klimt.font.UFontContext;
import net.sourceforge.plantuml.klimt.font.UFontFace;
import net.sourceforge.plantuml.klimt.geom.XDimension2D;
import net.sourceforge.plantuml.text.RichText;
import net.sourceforge.plantuml.text.StyledString;
/**
* Measures text with the metrics of the fonts embedded in PlantUML (Liberation
* Sans), without AWT, so that the TeaVM C build lays out exactly as the JVM does
* on Windows, and draws with the very same fonts.
*
* <p>
* It reproduces what {@code StringBounderAwt} gives on Windows, where Java maps
* its logical font "SansSerif" to Arial (whose metrics are Liberation Sans's):
* <ul>
* <li>a character is as wide as its glyph's advance (fractional metrics: no
* rounding, no kerning, no ligatures);</li>
* <li>a logical font is a composite: its line also makes room for the fonts it
* falls back on for other scripts, which raise its ascent and descent to at
* least {@link #LOGICAL_MIN_ASCENT} and {@link #LOGICAL_MIN_DESCENT}; and the
* characters it does not take from Arial (those Arial lacks, and those of
* Arial's exclusion ranges in Java's fontconfig.properties, such as dashes and
* typographic quotes) have the widths Java gives them on Windows, measured once
* by tools/font-metrics/MeasureLogicalFonts.java;</li>
* <li>a font named Arial (or Liberation Sans) is a physical font: its own line
* height, and its missing glyph for the characters it lacks.</li>
* </ul>
* Other families (serif, monospace...) are measured as "SansSerif" for now: only
* Liberation Sans is embedded.
*
* <p>
* Known difference: when a text contains a character of a complex script
* (combining marks U+0300-036F, Hebrew, Arabic, Indic, Thai, bidi controls...),
* Java2D shapes the whole text with HarfBuzz, which can move a mark's base or
* hide a soft hyphen. Other text (precomposed accents, Greek, Cyrillic...)
* measures exactly as with Java2D.
*/
public class StringBounderEmbeddedFont implements StringBounder {
/**
* Minimum ascent and descent of Java's logical fonts on Windows, in 2048ths of
* the size, as measured with Java on Windows by the rockuml project
* (https://github.com/h3ll5ur7er/rockuml).
*/
private static final double LOGICAL_MIN_ASCENT = 2059.0 / 2048.0;
private static final double LOGICAL_MIN_DESCENT = 450.0 / 2048.0;
private final FileFormat fileFormat;
private final boolean windowsWidths;
public StringBounderEmbeddedFont(FileFormat fileFormat) {
this(fileFormat, true);
}
private StringBounderEmbeddedFont(FileFormat fileFormat, boolean windowsWidths) {
this.fileFormat = fileFormat;
this.windowsWidths = windowsWidths;
}
/**
* A bounder without the widths measured with Java on Windows, for
* tools/font-metrics/MeasureLogicalFonts.java only: for the characters that
* do not come from Arial, logical fonts use PlantUML's width table.
*/
public static StringBounderEmbeddedFont withoutWindowsWidths(FileFormat fileFormat) {
return new StringBounderEmbeddedFont(fileFormat, false);
}
@Override
public FileFormat getFileFormat() {
return fileFormat;
}
@Override
public XDimension2D calculateDimension(UFont font, String text) {
// Same as StringBounderAwt
if (RichText.isRich(text)) {
double width = 0;
double height = 0;
for (StyledString s : StyledString.build(text)) {
final UFont newFont = s.getStyle().mutateFont(font);
final XDimension2D rect = calculateDimensionInternal(newFont, s.getText());
width += rect.getWidth();
height = Math.max(height, rect.getHeight());
}
return new XDimension2D(width, height);
}
return calculateDimensionInternal(font, text);
}
@Override
public double getDescent(UFont font, String text) {
final EmbeddedFontMetrics metrics = getMetrics(font.getFontFace());
final double size = font.getSize2D();
final double descent = relative(metrics, metrics.getDescent());
if (isLogical(font, text))
return Math.max(descent, LOGICAL_MIN_DESCENT) * size;
return descent * size;
}
private XDimension2D calculateDimensionInternal(UFont font, String text) {
if (text.length() == 0)
return new XDimension2D(0, 0);
final EmbeddedFontMetrics metrics = getMetrics(font.getFontFace());
final boolean logical = isLogical(font, text);
final double size = font.getSize2D();
final int style = getStyle(font.getFontFace());
double width = 0;
for (int i = 0; i < text.length();) {
final int cp = text.codePointAt(i);
width += getRelativeAdvance(metrics, logical, style, cp);
i += Character.charCount(cp);
}
double ascent = relative(metrics, metrics.getAscent());
double descent = relative(metrics, metrics.getDescent());
if (logical) {
ascent = Math.max(ascent, LOGICAL_MIN_ASCENT);
descent = Math.max(descent, LOGICAL_MIN_DESCENT);
}
final double leading = relative(metrics, metrics.getLineGap());
return new XDimension2D(width * size, (ascent + descent + leading) * size);
}
/**
* Advance of a character as a fraction of the font size.
*/
private double getRelativeAdvance(EmbeddedFontMetrics metrics, boolean logical, int style, int cp) {
if (isInvisible(cp))
return 0;
if (logical && windowsWidths) {
final int measured = LiberationSansMetrics.WINDOWS_SANS_SERIF.getWidth(cp, style);
if (measured >= 0)
return measured / 2048.0;
}
final int advance = metrics.getAdvance(cp);
if (advance >= 0)
return relative(metrics, advance);
if (logical)
return StringBounderFromWidthTable.getRelativeWidth(cp);
return relative(metrics, metrics.getMissingAdvance());
}
/**
* Characters Java2D replaces by an invisible glyph of no width, although the
* font has no glyph for them.
*/
private static boolean isInvisible(int cp) {
return cp == '\t' || cp == '\n' || cp == '\r' || cp == 0x2028 || cp == 0x2029;
}
private static double relative(EmbeddedFontMetrics metrics, int units) {
return units / (double) metrics.getUnitsPerEm();
}
private static int getStyle(UFontFace face) {
if (face.isBold())
return face.isItalic() ? MeasuredWidths.BOLD_ITALIC : MeasuredWidths.BOLD;
return face.isItalic() ? MeasuredWidths.ITALIC : MeasuredWidths.REGULAR;
}
private static EmbeddedFontMetrics getMetrics(UFontFace face) {
if (face.isBold())
return face.isItalic() ? LiberationSansMetrics.BOLD_ITALIC : LiberationSansMetrics.BOLD;
return face.isItalic() ? LiberationSansMetrics.ITALIC : LiberationSansMetrics.REGULAR;
}
/**
* Whether Java would use a logical (composite) font: anything but an explicit
* Arial, Java falling back to the logical font "Dialog" for unknown families.
*/
private static boolean isLogical(UFont font, String text) {
final String family = firstFamily(font.getFamily(text, UFontContext.SVG));
return family.equals("arial") == false && family.equals("liberation sans") == false;
}
static String firstFamily(String definition) {
final int comma = definition.indexOf(',');
final String first = comma == -1 ? definition : definition.substring(0, comma);
return StringUtils.goLowerCase(first.replace('"', ' ').replace('\'', ' ').trim());
}
}