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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ follow semantic versioning; release dates are ISO 8601.

### Fixed

- **The donut-centre KPI renders the weight it declares.** A style names a font
*family* and a *decoration*, and the decoration is what picks the face within the
family — the standard-14 face constants (`HELVETICA_BOLD`, `TIMES_ITALIC`, …) are
aliases of their family and carry no weight of their own. The chart default named
the bold face and set no decoration, so it rendered regular, measured with regular
metrics, in every donut chart that did not override the centre style. Nothing
announced it: the text laid out and drew. `FontLibrary` now logs one warning per
face constant it rewrites, the sixteen places in the library that named a face
redundantly name their family instead, and the resolved glyph program is pinned by
test — both the rule and the donut default itself, since the rule alone would not
catch the site going back. Three rendered documents change — the engine
deck, the feature catalogue and the chart showcase. ([#451](https://github.com/DemchaAV/GraphCompose/issues/451))


- **A heading no longer strands above a block that was asked to stay whole.**
`keepWithNext()` decides by asking whether the heading plus the *first line* of
the next block fits, but a `keepTogether()` block has no first line to break
Expand Down
Binary file modified assets/readme/examples/chart-showcase.pdf
Binary file not shown.
Binary file modified assets/readme/examples/engine-deck.pdf
Binary file not shown.
Binary file modified assets/readme/examples/feature-catalog.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentPaint;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;

Expand Down Expand Up @@ -98,7 +99,8 @@ public final class ChartDefaults {
* Default donut-centre KPI text style.
*/
public static final DocumentTextStyle DONUT_CENTER_TEXT_STYLE = DocumentTextStyle.builder()
.fontName(FontName.HELVETICA_BOLD)
.fontName(FontName.HELVETICA)
.decoration(DocumentTextDecoration.BOLD)
.size(13)
.color(DocumentColor.rgb(45, 45, 45))
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
public final class HeadingBarStyle {

private static final DocumentTextStyle DEFAULT_TEXT = DocumentTextStyle.builder()
.fontName(FontName.HELVETICA_BOLD)
.fontName(FontName.HELVETICA)
.decoration(DocumentTextDecoration.BOLD)
.size(11)
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ private static boolean notBlank(String value) {

private static DocumentTextStyle defaultTitleStyle() {
return DocumentTextStyle.builder()
.fontName(FontName.HELVETICA_BOLD)
.fontName(FontName.HELVETICA)
.decoration(DocumentTextDecoration.BOLD)
.size(11)
.color(DEFAULT_INK)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public static TimelineMarker circle(double size, DocumentColor fill, DocumentStr
public static TimelineMarker numbered(int number, double size,
DocumentColor fill, DocumentColor textColor) {
DocumentTextStyle label = DocumentTextStyle.builder()
.fontName(FontName.HELVETICA_BOLD)
.fontName(FontName.HELVETICA)
.decoration(DocumentTextDecoration.BOLD)
.size(Math.max(6.0, size * 0.5))
.color(textColor == null ? DocumentColor.WHITE : textColor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
* <p>The DSL adapts this value into the internal engine text style during
* composition. Instances are immutable and thread-safe.</p>
*
* @param fontName font family name
* @param fontName font <em>family</em> name. The standard-14 face constants
* ({@code HELVETICA_BOLD}, {@code TIMES_ITALIC}, …) are aliases of
* their family and carry no weight or slant of their own — the face
* comes from {@code decoration}. Naming a face and leaving the
* decoration unset renders the regular face.
* @param size font size in points
* @param decoration text decoration
* @param color text color
Expand Down
38 changes: 37 additions & 1 deletion core/src/main/java/com/demcha/compose/font/FontLibrary.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.demcha.compose.font;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
Expand All @@ -22,6 +25,15 @@
*/
public class FontLibrary {

private static final Logger LOG = LoggerFactory.getLogger(FontLibrary.class);

/**
* Face aliases already reported, so the warning costs one line per name, not per
* lookup. Package-private so the guard covering the warning can start from a known
* state — a static cache is otherwise order-dependent across a test class.
*/
static final Set<FontName> WARNED_FACE_ALIASES = ConcurrentHashMap.newKeySet();

private static final Map<FontName, FontName> FONT_ALIASES = Map.ofEntries(
Map.entry(FontName.HELVETICA_BOLD, FontName.HELVETICA),
Map.entry(FontName.HELVETICA_OBLIQUE, FontName.HELVETICA),
Expand Down Expand Up @@ -147,6 +159,30 @@ private FontName resolveBaseFont(FontName fontName) {
if (fontName == null || FontName.DEFAULT.equals(fontName)) {
return FontName.HELVETICA;
}
return FONT_ALIASES.getOrDefault(fontName, fontName);
FontName base = FONT_ALIASES.get(fontName);
if (base == null) {
return fontName;
}
warnOnceAboutFaceAlias(fontName, base);
return base;
}

/**
* Warns the first time a style selects a standard-14 face by name.
*
* <p>A name like {@code HELVETICA_BOLD} is an alias of its family: it is rewritten
* to {@code HELVETICA} here, and the face is chosen later from the style's
* decoration. Naming the face therefore contributes nothing, and a style that names
* it and sets no decoration renders regular — silently, since the text still lays
* out and still draws. One line per distinct alias, so a document that uses the form
* a thousand times says so once.</p>
*/
private static void warnOnceAboutFaceAlias(FontName requested, FontName base) {
if (WARNED_FACE_ALIASES.add(requested)) {
LOG.warn("fontName({}) selects the {} family, not a face — the face comes from the "
+ "style's decoration. Name the family and set decoration(...) instead; "
+ "as written this renders {} unless a decoration says otherwise.",
requested.name(), base.name(), base.name());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.demcha.compose.font;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Covers the signal that makes the face-alias trap visible.
*
* <p>A standard-14 face constant is an alias of its family, so a style naming one and
* setting no decoration renders regular without failing, logging or measuring
* differently. The warning is the whole of what makes that observable, which makes it
* worth a test rather than a hope.</p>
*
* <p><strong>Known limit, asserted here rather than left implicit:</strong> the warning
* is keyed on the alias alone, not on the pairing of alias and decoration. The library
* cannot see the decoration at the point it rewrites the name. So the first use of
* {@code HELVETICA_BOLD} consumes the warning even when that use is correct, and a later
* broken one is silent. The signal catches a codebase that uses the form, not the
* individual style that gets it wrong; {@code DocsBoldFaceGuardTest} and
* {@code FontFaceResolutionTest} cover the specific sites.</p>
*/
class FontFaceAliasWarningTest {

private ListAppender<ILoggingEvent> appender;
private ch.qos.logback.classic.Logger logger;

@BeforeEach
void captureWarnings() {
FontLibrary.WARNED_FACE_ALIASES.clear();
logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(FontLibrary.class);
appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
logger.setLevel(Level.WARN);
}

@AfterEach
void releaseAppender() {
logger.detachAppender(appender);
FontLibrary.WARNED_FACE_ALIASES.clear();
}

private List<String> warnings() {
return appender.list.stream()
.filter(event -> event.getLevel() == Level.WARN)
.map(ILoggingEvent::getFormattedMessage)
.toList();
}

@Test
void namingAFaceWarnsOnceAndNamesBothTheConstantAndItsFamily() {
FontLibrary library = new FontLibrary();

library.getFont(FontName.HELVETICA_BOLD, Object.class);

assertThat(warnings())
.describedAs("naming a face must say so — it is the only signal that the "
+ "constant carries no weight of its own")
.hasSize(1);
assertThat(warnings().get(0))
.contains(FontName.HELVETICA_BOLD.name())
.contains(FontName.HELVETICA.name())
.contains("decoration");
}

@Test
void repeatingTheSameFaceDoesNotRepeatTheWarning() {
FontLibrary library = new FontLibrary();

for (int i = 0; i < 50; i++) {
library.getFont(FontName.HELVETICA_BOLD, Object.class);
}

assertThat(warnings())
.describedAs("the warning is per name, not per lookup: a document that uses the "
+ "form on every span would otherwise bury its own output")
.hasSize(1);
}

@Test
void eachFaceGetsItsOwnWarning() {
FontLibrary library = new FontLibrary();

library.getFont(FontName.HELVETICA_BOLD, Object.class);
library.getFont(FontName.TIMES_BOLD, Object.class);
library.getFont(FontName.COURIER_OBLIQUE, Object.class);

assertThat(warnings()).hasSize(3);
assertThat(warnings()).anyMatch(message -> message.contains(FontName.TIMES_ROMAN.name()));
}

@Test
void namingAFamilyIsSilent() {
FontLibrary library = new FontLibrary();

library.getFont(FontName.HELVETICA, Object.class);
library.getFont(FontName.TIMES_ROMAN, Object.class);
library.getFont(FontName.COURIER, Object.class);

assertThat(warnings())
.describedAs("the correct form must not be noisy, or the signal is worthless")
.isEmpty();
}
}
22 changes: 22 additions & 0 deletions docs/font-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ Not every character can be drawn by every font. This page explains what the
built-in PDF fonts can encode, why an unexpected `?` sometimes appears, and the
three ways to render the symbol you actually wanted.

## The name picks the family, the decoration picks the face

A style names a family and a decoration, and those are two different choices:

```java
DocumentTextStyle.builder()
.fontName(FontName.HELVETICA) // family
.decoration(DocumentTextDecoration.BOLD) // face within it
.size(28)
.build();
```

The standard-14 face constants — `HELVETICA_BOLD`, `TIMES_ITALIC`,
`COURIER_BOLD_OBLIQUE` and the rest — are **aliases of their family**, not faces.
`FontLibrary` rewrites each one to its base family before any lookup, so naming a
face contributes nothing and the decoration alone decides the glyph program. A
style that names `HELVETICA_BOLD` and sets no decoration renders regular
Helvetica, measured with regular metrics; the text lays out and draws, so nothing
announces it. The library logs one warning per such name to make it visible.

Name the family, set the decoration.

## WinAnsi and the base-14 fonts

The built-in fonts — `HELVETICA`, `TIMES`, `COURIER` and their bold / italic
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.demcha.compose.document.backend.fixed.pdf;

import com.demcha.compose.document.chart.ChartDefaults;
import com.demcha.compose.engine.render.pdf.PdfFont;
import com.demcha.compose.engine.components.content.text.TextDecoration;
import com.demcha.compose.font.FontLibrary;
import com.demcha.compose.font.FontName;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Pins the rule that decides which glyph program a style actually gets: the font
* <em>name</em> selects the family, the <em>decoration</em> selects the face within it.
*
* <p>The rule is easy to state and was invisible in the suite. {@code FontLibrary}
* rewrites every standard-14 face constant to its base family before any lookup, so
* {@code fontName(HELVETICA_BOLD)} contributes nothing and a style that sets no
* decoration renders regular — silently, because the text still measures and still
* draws. That is what the donut-centre KPI did.</p>
*
* <p>Asserted on the resolved {@code PDFont}, which is the glyph program the page
* actually references, rather than on a rendered image: a weight difference is a font
* resource difference, and reading it directly says which face without a pixel
* threshold to argue about.</p>
*
* <p>These assertions are the ones that must change if the alias is ever made a real
* fallback — which makes the behaviour change deliberate instead of incidental.</p>
*/
class FontFaceResolutionTest {

private static final FontLibrary LIBRARY = PdfFontLibraryFactory.standardLibrary();

private static String face(FontName name, TextDecoration decoration) {
PdfFont font = LIBRARY.getFont(name, PdfFont.class).orElseThrow();
return font.fontType(decoration).getName();
}

@Test
void theDecorationSelectsTheFaceWithinTheFamily() {
assertThat(face(FontName.HELVETICA, TextDecoration.DEFAULT)).isEqualTo("Helvetica");
assertThat(face(FontName.HELVETICA, TextDecoration.BOLD)).isEqualTo("Helvetica-Bold");
assertThat(face(FontName.TIMES_ROMAN, TextDecoration.BOLD)).isEqualTo("Times-Bold");
assertThat(face(FontName.COURIER, TextDecoration.BOLD)).isEqualTo("Courier-Bold");
}

@Test
void aFaceConstantIsAnAliasOfItsFamilyAndCarriesNoWeightOfItsOwn() {
assertThat(face(FontName.HELVETICA_BOLD, TextDecoration.DEFAULT))
.describedAs("naming the bold face without a decoration renders regular — "
+ "the constant is rewritten to its family before the lookup")
.isEqualTo("Helvetica");
assertThat(face(FontName.TIMES_BOLD, TextDecoration.DEFAULT)).isEqualTo("Times-Roman");
assertThat(face(FontName.COURIER_BOLD, TextDecoration.DEFAULT)).isEqualTo("Courier");

assertThat(face(FontName.HELVETICA_BOLD, TextDecoration.BOLD))
.describedAs("the alias and the family resolve identically once a decoration "
+ "is set, which is why naming the family is the clearer form")
.isEqualTo(face(FontName.HELVETICA, TextDecoration.BOLD));
}

/**
* The defect this rule was traced from, pinned at its own site.
*
* <p>Asserting the rule alone would not catch the regression: a style that goes back
* to naming the bold face and dropping the decoration still satisfies every general
* assertion above. The chart layout tests do not catch it either — they measure
* through fake metrics whose width depends on the character count and ignores the
* face — so without this the donut KPI could quietly return to regular.</p>
*/
@Test
void theDonutCentreDefaultResolvesToABoldFace() {
assertThat(face(ChartDefaults.DONUT_CENTER_TEXT_STYLE.fontName(),
TextDecoration.valueOf(ChartDefaults.DONUT_CENTER_TEXT_STYLE.decoration().name())))
.describedAs("the donut-centre KPI must resolve to a bold glyph program. Naming "
+ "the bold face and setting no decoration renders regular — that is the "
+ "defect, and it is invisible to the chart layout tests")
.isEqualTo("Helvetica-Bold");
}

@Test
void anAliasedNameFollowsTheDecorationEvenWhenTheyDisagree() {
assertThat(face(FontName.HELVETICA_BOLD, TextDecoration.ITALIC))
.describedAs("the name says bold and the decoration says italic; the decoration "
+ "wins, so the constant is actively misleading rather than redundant")
.isEqualTo("Helvetica-Oblique");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public void compose(DocumentSession document, CoverLetterDocument doc) {
}

private Masthead.Style mastheadStyle() {
DocumentTextStyle nameStyle = TextStyles.of(FontName.HELVETICA_BOLD,
DocumentTextStyle nameStyle = TextStyles.of(FontName.HELVETICA,
theme.typography().sizeHeadline(),
DocumentTextDecoration.BOLD, NAME_COLOR);
DocumentTextStyle titleStyle = TextStyles.of(FontName.HELVETICA,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public void compose(DocumentSession document, CoverLetterDocument doc) {
Objects.requireNonNull(document, "document");
Objects.requireNonNull(doc, "doc");

DocumentTextStyle nameStyle = TextStyles.of(FontName.HELVETICA_BOLD,
DocumentTextStyle nameStyle = TextStyles.of(FontName.HELVETICA,
theme.typography().sizeHeadline(),
DocumentTextDecoration.BOLD, NAME_COLOR);
DocumentTextStyle contactBodyStyle = TextStyles.of(FontName.HELVETICA,
Expand Down
Loading
Loading