diff --git a/CHANGELOG.md b/CHANGELOG.md index a89d6cfc..cee5bdae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index b9abe0d3..d10c4e3a 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -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. diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java index 2966ba23..7aa46b9e 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java @@ -65,6 +65,7 @@ public final class PptxRenderEnvironment { private final List bookmarkRecords = new ArrayList<>(); private final Map anchorDestinations = new LinkedHashMap<>(); private final List fragmentLinks = new ArrayList<>(); + private final Set substitutionWarned = new LinkedHashSet<>(); PptxRenderEnvironment(XMLSlideShow show, PptxRenderSession session, @@ -81,9 +82,22 @@ public final class PptxRenderEnvironment { Map 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()); } }); } @@ -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; } /** diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMapping.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMapping.java index 5b739faa..68cde5d3 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMapping.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMapping.java @@ -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"; } @@ -51,7 +67,7 @@ public static String familyFor(FontName fontName) { if (lower.startsWith("courier")) { return "Courier New"; } - return stripStyleSuffix(name); + return null; } /** diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFontShowcaseDemoTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFontShowcaseDemoTest.java new file mode 100644 index 00000000..bb8ae9c5 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFontShowcaseDemoTest.java @@ -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 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; + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFontSubstitutionWarningTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFontSubstitutionWarningTest.java new file mode 100644 index 00000000..97abb8bc --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFontSubstitutionWarningTest.java @@ -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 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 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")); + } + } +}