diff --git a/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java b/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java index f362219b3..64f1c0421 100644 --- a/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java +++ b/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java @@ -81,7 +81,8 @@ * {@code cs}, {@code CS}, {@code sc}, {@code SC}, {@code scn}, {@code SCN} *
  • Text state: {@code BT}, {@code ET}, {@code Tf}, {@code Tc}, {@code Tw}, * {@code TL}, {@code Tz}, {@code Td}, {@code TD}, {@code Tm}, {@code T*}, - * {@code Ts} (text rise)
  • + * {@code Ts} (text rise), {@code Tr} (text rendering mode: fill / stroke / + * invisible; clipping modes 4-7 fall back to their non-clipping counterpart) *
  • Text showing: {@code Tj}, {@code TJ}, {@code '}, {@code "}
  • *
  • Marked content / compatibility (no-op): {@code BMC}, {@code BDC}, * {@code EMC}, {@code MP}, {@code DP}, {@code BX}, {@code EX}
  • @@ -900,6 +901,9 @@ private void dispatch(String op, List operands) { case "Ts": state.textRise = num(operands, 0); break; + case "Tr": + state.renderMode = (int) num(operands, 0); + break; case "Td": textMoveTo(num(operands, 0), num(operands, 1)); break; @@ -1561,23 +1565,21 @@ private void showText(String text) { if (!inTextObject || text == null || text.isEmpty()) { return; } - Font awtFont = mapFont(state.font, state.fontSize); - g2.setFont(awtFont); - g2.setColor(state.fillColor); - - AffineTransform saved = g2.getTransform(); - try { - // Text matrix maps text-space to user space; we then need a Y-flip - // because Graphics2D's font baseline is drawn in image-Y orientation. - g2.transform(textMatrix); - g2.scale(state.horizontalScaling, 1.0); - if (state.textRise != 0f) { - g2.translate(0, state.textRise); + if (!isInvisibleRenderMode(state.renderMode)) { + AffineTransform saved = g2.getTransform(); + try { + // Text matrix maps text-space to user space; we then need a Y-flip + // because Graphics2D's font baseline is drawn in image-Y orientation. + g2.transform(textMatrix); + g2.scale(state.horizontalScaling, 1.0); + if (state.textRise != 0f) { + g2.translate(0, state.textRise); + } + g2.scale(1, -1); + drawGlyphs(text); + } finally { + g2.setTransform(saved); } - g2.scale(1, -1); - g2.drawString(text, 0f, 0f); - } finally { - g2.setTransform(saved); } // Advance text matrix using actual PDF font widths + char/word spacing. @@ -1587,6 +1589,57 @@ private void showText(String text) { textMatrix = adv; } + /** + * Draws {@code text} at the origin of the (already positioned) current transform, + * honoring the active PDF §9.3.3 text rendering mode ({@code Tr}). + * + *

    Fill-only text (the common case, mode 0) uses {@link Graphics2D#drawString} + * directly. Stroke modes (1, 2, 5, 6) need the glyph outlines so they can be + * stroked with the current line width / dash / join settings, so those go through + * {@link Font#createGlyphVector}. Modes 4-7 (add to clipping path) fall back to + * their non-clipping counterpart since text clipping isn't implemented.

    + */ + private void drawGlyphs(String text) { + boolean fill = fillsRenderMode(state.renderMode); + boolean stroke = strokesRenderMode(state.renderMode); + Font awtFont = mapFont(state.font, state.fontSize); + if (fill && !stroke) { + g2.setFont(awtFont); + g2.setColor(state.fillColor); + g2.drawString(text, 0f, 0f); + return; + } + Shape outline = awtFont.createGlyphVector(g2.getFontRenderContext(), text).getOutline(0f, 0f); + if (fill) { + g2.setColor(state.fillColor); + g2.fill(outline); + } + if (stroke) { + g2.setColor(state.strokeColor); + g2.setStroke(new BasicStroke( + effectiveLineWidth(), + state.lineCap, state.lineJoin, state.miterLimit, + state.dashPattern, state.dashPhase)); + g2.draw(outline); + } + } + + /** + * PDF §9.3.3 text rendering modes 3 and 7 render nothing (7 only adds the glyphs to + * the clipping path, which we don't implement, so it degrades to fully invisible). + */ + private static boolean isInvisibleRenderMode(int mode) { + return mode == 3 || mode == 7; + } + + private static boolean fillsRenderMode(int mode) { + return mode == 0 || mode == 2 || mode == 4 || mode == 6; + } + + private static boolean strokesRenderMode(int mode) { + return mode == 1 || mode == 2 || mode == 5 || mode == 6; + } + private void showTextArray(PdfArray array) { for (PdfObject obj : array.getElements()) { if (obj instanceof PdfString s) { @@ -1904,6 +1957,11 @@ private static final class GState { float leading; float horizontalScaling = 1.0f; float textRise; + // PDF §9.3.3 text rendering mode (Tr): 0=fill, 1=stroke, 2=fill+stroke, 3=invisible, + // 4-7 add the glyphs to the clipping path in addition to the 0-3 behavior. Clipping + // is not implemented; modes 4-7 fall back to their 0-3 counterpart (see + // fillsRenderMode/strokesRenderMode/isInvisibleRenderMode). + int renderMode; GState() { } @@ -1929,6 +1987,7 @@ private static final class GState { this.leading = other.leading; this.horizontalScaling = other.horizontalScaling; this.textRise = other.textRise; + this.renderMode = other.renderMode; // hasPendingClip / pendingClipRule are intentionally not copied: // the W / W* operators apply to the current path before any q/Q boundary. } diff --git a/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java b/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java index f63520123..404b73442 100644 --- a/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java +++ b/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java @@ -519,6 +519,78 @@ void rendersPdfPTableWithBordersFillsAndText() throws Exception { } } + /** + * PDF §9.3.3: text rendering mode 3 ({@code Tr 3}) means the glyphs are neither + * filled nor stroked — the "invisible text" mode used by scanned-PDF / + * OCR text layers, where an invisible text layer sits on top of a page-image + * XObject so the text stays selectable/searchable without being visible. Before + * {@code Tr} support, the renderer always filled text regardless of render mode, + * which would incorrectly paint OCR text layers over their background image. + */ + @Test + void invisibleTextRenderModeDoesNotPaintGlyphs() throws Exception { + byte[] pdf = buildPdf(cb -> { + org.openpdf.text.pdf.BaseFont bf = org.openpdf.text.pdf.BaseFont + .createFont(org.openpdf.text.pdf.BaseFont.HELVETICA, + org.openpdf.text.pdf.BaseFont.WINANSI, + org.openpdf.text.pdf.BaseFont.NOT_EMBEDDED); + cb.beginText(); + cb.setFontAndSize(bf, 48f); + cb.setTextRenderingMode(PdfContentByte.TEXT_RENDER_MODE_INVISIBLE); + cb.setTextMatrix(20f, 100f); + cb.showText("HIDDEN"); + cb.endText(); + }); + + try (OpenPdfCoreRenderer r = new OpenPdfCoreRenderer(pdf)) { + List ops = r.getContentOperators(1); + assertThat(ops).contains("Tr", "Tj"); + + BufferedImage img = r.renderPage(1, 150f); + saveForInspection(img, "invisible-text.png"); + + int darkPixels = countPixelsMatching(img, (red, green, blue) -> + red < 80 && green < 80 && blue < 80); + assertThat(darkPixels) + .as("Tr 3 (invisible) text must not paint any glyph pixels") + .isZero(); + } + } + + /** + * PDF §9.3.3: text rendering mode 1 ({@code Tr 1}) strokes the glyph outlines + * instead of filling them. Verifies the renderer draws the stroke in the active + * stroke color rather than silently falling back to a filled glyph. + */ + @Test + void strokeTextRenderModeUsesStrokeColor() throws Exception { + byte[] pdf = buildPdf(cb -> { + org.openpdf.text.pdf.BaseFont bf = org.openpdf.text.pdf.BaseFont + .createFont(org.openpdf.text.pdf.BaseFont.HELVETICA, + org.openpdf.text.pdf.BaseFont.WINANSI, + org.openpdf.text.pdf.BaseFont.NOT_EMBEDDED); + cb.setRGBColorStrokeF(1f, 0f, 0f); // red stroke + cb.setLineWidth(1.5f); + cb.beginText(); + cb.setFontAndSize(bf, 48f); + cb.setTextRenderingMode(PdfContentByte.TEXT_RENDER_MODE_STROKE); + cb.setTextMatrix(20f, 100f); + cb.showText("OUTLINE"); + cb.endText(); + }); + + try (OpenPdfCoreRenderer r = new OpenPdfCoreRenderer(pdf)) { + BufferedImage img = r.renderPage(1, 150f); + saveForInspection(img, "stroke-text.png"); + + int redish = countPixelsMatching(img, (red, green, blue) -> + red > 150 && green < 100 && blue < 100); + assertThat(redish) + .as("Tr 1 (stroke) text must paint glyph outlines in the stroke color") + .isGreaterThan(10); + } + } + /** * PDF §8.4.3.2: a stroke width of 0 means "the thinnest line the device can * render", i.e. one device pixel. Naively passing the user-space width to