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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ follow semantic versioning; release dates are ISO 8601.
PowerPoint tables, which re-lay-out their content. Contiguous table rows
paint all fills before any border or text, the PDF backend's two-pass
discipline, and row-spanning cells grow downward through their merged rows.
- The fixed PPTX backend warns explicitly whenever a font cannot travel into
the deck identically — once per family per render: standard-14 families name
their metric-compatible replacement (Helvetica → Arial), families registered
without binary sources and unregistered names are flagged as name-only
references that viewers may substitute, and license-restricted embeds
already degrade with a warning. An embedded family stays silent.
- `PptxFixedLayoutBackend.Builder.rasterSlides(int dpi)` — raster-slide mode:
every page renders through the PDF backend and lands as one full-slide
picture, a pixel-exact copy of the PDF/PNG output for decks that must look
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@ with the fonts installed on the viewing machine. Standard-14 font names are mapp
metric-compatible system fonts (Helvetica → Arial, Times → Times New
Roman, Courier → Courier New); document-local fonts use their registered
viewer-facing `wordFamily` and should be installed on the viewer for exact glyphs.
Every substitution is explicit: the backend logs
`render.pptx.font.substitution` once per family per render whenever a
font travels as a standard-14 replacement or a name-only reference
instead of an embedded program; embedded families render silently.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public final class PptxRenderEnvironment {
private final List<BookmarkRecord> bookmarkRecords = new ArrayList<>();
private final Map<String, AnchorDestination> anchorDestinations = new LinkedHashMap<>();
private final List<FragmentLink> fragmentLinks = new ArrayList<>();
private final Set<String> substitutionWarned = new LinkedHashSet<>();

PptxRenderEnvironment(XMLSlideShow show,
PptxRenderSession session,
Expand All @@ -81,9 +82,22 @@ public final class PptxRenderEnvironment {
Map<FontName, PptxViewerMetrics.ViewerFontMetrics> metrics = new LinkedHashMap<>();
for (FontFamilyDefinition family : customFontFamilies) {
familyNames.put(family.name(), family.wordFamily());
if (family.fontSourceSet().isEmpty()) {
LOG.warn("render.pptx.font.substitution family={} — registered without binary "
+ "sources, the deck references \"{}\" by name only; viewers "
+ "without that font installed will substitute their own",
family.name().name(), family.wordFamily());
}
family.fontSourceSet().ifPresent(sources -> {
if (embedFontFamily(show, family, sources)) {
metrics.put(family.name(), PptxViewerMetrics.load(family, sources));
} else {
// Same key as every other substitution so one log filter
// catches them all; the embed-skip warning carries the cause.
LOG.warn("render.pptx.font.substitution family={} — could not be embedded, "
+ "the deck references \"{}\" by name only; viewers without "
+ "that font installed will substitute their own",
family.name().name(), family.wordFamily());
}
});
}
Expand Down Expand Up @@ -156,7 +170,30 @@ public String fontFamily(FontName fontName) {
return customFamily;
}
}
return PptxFontMapping.familyFor(fontName);
// Dedupe on the resolved viewer family so facet-suffixed names
// (Roboto-Regular, Roboto-Bold) warn once, not once per facet; the
// message still names the source font the document asked for.
String viewerFamily = PptxFontMapping.familyFor(fontName);
String sourceName = (fontName == null ? FontName.HELVETICA : fontName).name();
String replacement = PptxFontMapping.standardReplacementFor(fontName);
if (replacement != null) {
if (substitutionWarned.add(viewerFamily)) {
LOG.warn("render.pptx.font.substitution family={} — a PDF built-in with no "
+ "PPTX counterpart, rendered as metric-compatible \"{}\" "
+ "(identical widths, slightly different letterforms); register "
+ "a binary font family for identical glyphs in both formats",
sourceName, replacement);
}
} else if (substitutionWarned.add(viewerFamily)) {
// Not registered for this render: the deck can only reference the
// family by name, so a viewer without it installed substitutes.
LOG.warn("render.pptx.font.substitution family={} — not registered with binary "
+ "sources for this render, the deck references \"{}\" by name "
+ "only; register the family (registerFontFamily) to embed it and "
+ "guarantee identical glyphs on every machine",
sourceName, viewerFamily);
}
return viewerFamily;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,24 @@ private PptxFontMapping() {
* @return PPTX family name
*/
public static String familyFor(FontName fontName) {
String name = (fontName == null ? FontName.HELVETICA : fontName).name();
String lower = name.toLowerCase(Locale.ROOT);
String replacement = standardReplacementFor(fontName);
if (replacement != null) {
return replacement;
}
return stripStyleSuffix((fontName == null ? FontName.HELVETICA : fontName).name());
}

/**
* Returns the metric-compatible viewer family that replaces a standard-14
* PostScript font in PPTX output, or {@code null} when the name is not a
* standard-14 family (binary families keep their own name).
*
* @param fontName logical document font
* @return replacement family name, or {@code null}
*/
public static String standardReplacementFor(FontName fontName) {
String lower = (fontName == null ? FontName.HELVETICA : fontName)
.name().toLowerCase(Locale.ROOT);
if (lower.startsWith("helvetica")) {
return "Arial";
}
Expand All @@ -51,7 +67,7 @@ public static String familyFor(FontName fontName) {
if (lower.startsWith("courier")) {
return "Courier New";
}
return stripStyleSuffix(name);
return null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.demcha.compose.document.backend.fixed.pptx;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontFamilyDefinition;
import com.demcha.compose.font.FontName;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.junit.jupiter.api.Test;

import javax.imageio.ImageIO;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

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

/**
* Font-identity specimen: four embedded families (sans, geometric sans, mono,
* serif) with regular/bold/italic runs and a size ramp, rendered to the
* PDF/PPTX pair. Both outputs embed the same TTF programs, so a real viewer
* shows identical glyphs; the specimen exists so that claim can be checked by
* eye, page against slide.
*/
class PptxFontShowcaseDemoTest {

private static final String SPECIMEN = "Grumpy wizards 1234567890 fjord-quiz";

private record Family(FontName name, String folder, String file, String viewerFamily) {
}

private static final List<Family> FAMILIES = List.of(
new Family(FontName.of("ShowcaseLato"), "lato", "Lato", "Lato"),
new Family(FontName.of("ShowcasePoppins"), "poppins", "Poppins", "Poppins"),
new Family(FontName.of("ShowcaseJetBrainsMono"), "jetbrainsmono", "JetBrainsMono",
"JetBrains Mono"),
new Family(FontName.of("ShowcaseSpectral"), "spectral", "Spectral", "Spectral"));

@Test
void writesTheFontIdentitySpecimenPair() throws Exception {
Path output = Path.of("target", "visual-tests", "pptx-parity", "font-identity");
Files.createDirectories(output);

try (DocumentSession session = composeSpecimen()) {
byte[] pdf = session.toPdfBytes();
Files.write(output.resolve("font-identity.pdf"), pdf);
byte[] pptx = session.render(new PptxFixedLayoutBackend());
Files.write(output.resolve("font-identity.pptx"), pptx);

var pdfPages = session.toImages(144);
for (int i = 0; i < pdfPages.size(); i++) {
ImageIO.write(pdfPages.get(i), "png",
output.resolve("font-identity-page-" + (i + 1) + ".pdf.png").toFile());
}
try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
assertThat(show.getSlides()).hasSameSizeAs(pdfPages);
var embeddedList = show.getCTPresentation().getEmbeddedFontLst();
assertThat(embeddedList).as("the deck must embed the specimen fonts").isNotNull();
assertThat(embeddedList.getEmbeddedFontList())
.extracting(entry -> entry.getFont().getTypeface())
.as("all four families travel as embedded programs")
.containsExactlyInAnyOrder("Lato", "Poppins", "JetBrains Mono", "Spectral");
}
}
}

private static DocumentSession composeSpecimen() {
var builder = GraphCompose.document()
.pageSize(560, 420)
.margin(DocumentInsets.of(28));
for (Family family : FAMILIES) {
builder.registerFontFamily(FontFamilyDefinition.classpath(
family.name(), "fonts/google/" + family.folder()
+ "/" + family.file() + "-Regular.ttf")
.boldResource("fonts/google/" + family.folder()
+ "/" + family.file() + "-Bold.ttf")
.italicResource("fonts/google/" + family.folder()
+ "/" + family.file() + "-Italic.ttf")
.boldItalicResource("fonts/google/" + family.folder()
+ "/" + family.file() + "-BoldItalic.ttf")
.wordFamily(family.viewerFamily())
.build());
}
DocumentSession session = builder.create();
session.pageFlow(page -> page.module("specimen", module -> {
module.paragraph(p -> p.text("Embedded font identity — PDF vs PPTX")
.textStyle(DocumentTextStyle.builder()
.fontName(FAMILIES.get(0).name()).size(20)
.decoration(DocumentTextDecoration.BOLD)
.color(DocumentColor.rgb(15, 23, 42)).build()));
for (Family family : FAMILIES) {
module.paragraph(p -> p.text(family.viewerFamily())
.textStyle(DocumentTextStyle.builder()
.fontName(family.name()).size(9)
.color(DocumentColor.GRAY).build()));
module.paragraph(p -> p.rich(r -> r
.plain(SPECIMEN + " ")
.bold("bold")
.plain(" ")
.italic("italic"))
.textStyle(DocumentTextStyle.builder()
.fontName(family.name()).size(13).build()));
}
module.paragraph(p -> p.text("Size ramp:")
.textStyle(DocumentTextStyle.builder()
.fontName(FAMILIES.get(0).name()).size(9)
.color(DocumentColor.GRAY).build()));
for (int size : new int[]{10, 14, 18, 24}) {
module.paragraph(p -> p.text(size + " pt " + SPECIMEN)
.textStyle(DocumentTextStyle.builder()
.fontName(FAMILIES.get(0).name()).size(size).build()));
}
}));
return session;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.demcha.compose.document.backend.fixed.pptx;

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources;
import com.demcha.compose.font.FontFamilyDefinition;
import com.demcha.compose.font.FontName;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
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;

/**
* A font that cannot travel into the deck identically must say so: standard-14
* families warn about their metric-compatible replacement, unregistered names
* warn that the deck carries a name-only reference, and each family warns once
* per render pass. An embedded family stays silent.
*/
class PptxFontSubstitutionWarningTest {

private static final FontName EMBEDDED = FontName.of("WarnLato");
private static final FontFamilyDefinition EMBEDDED_FAMILY = FontFamilyDefinition.classpath(
EMBEDDED, "fonts/google/lato/Lato-Regular.ttf")
.wordFamily("Lato")
.build();

private ListAppender<ILoggingEvent> appender;
private Logger logger;

@BeforeEach
void attachAppender() {
logger = (Logger) LoggerFactory.getLogger("com.demcha.compose.engine.render");
appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
}

@AfterEach
void detachAppender() {
logger.detachAppender(appender);
appender.stop();
}

@Test
void substitutedFamiliesWarnOncePerRenderPassAndEmbeddedFamiliesStaySilent() throws Exception {
try (XMLSlideShow show = new XMLSlideShow();
PdfMeasurementResources measurement = PdfMeasurementResources.open(List.of(EMBEDDED_FAMILY))) {
PptxRenderSession session = new PptxRenderSession(show, 300, 200, 1);
PptxRenderEnvironment environment = new PptxRenderEnvironment(
show, session, 0, 200, measurement.fontLibrary(), List.of(EMBEDDED_FAMILY));

assertThat(environment.fontFamily(FontName.HELVETICA)).isEqualTo("Arial");
assertThat(environment.fontFamily(FontName.HELVETICA)).isEqualTo("Arial");
assertThat(environment.fontFamily(FontName.of("Roboto-Regular"))).isEqualTo("Roboto");
assertThat(environment.fontFamily(FontName.of("Roboto-Bold")))
.as("facet of an already-warned family")
.isEqualTo("Roboto");
assertThat(environment.fontFamily(EMBEDDED)).isEqualTo("Lato");

List<String> warnings = appender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.filter(message -> message.startsWith("render.pptx.font.substitution"))
.toList();
assertThat(warnings)
.as("one warning per substituted family, none for the embedded one")
.hasSize(2);
assertThat(warnings.get(0))
.contains("Helvetica")
.contains("Arial")
.contains("metric-compatible");
assertThat(warnings.get(1))
.contains("Roboto-Regular")
.contains("by name");
assertThat(warnings).noneMatch(message -> message.contains("WarnLato"));
}
}
}
Loading