Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@
* {@code cs}, {@code CS}, {@code sc}, {@code SC}, {@code scn}, {@code SCN}</li>
* <li>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)</li>
* {@code Ts} (text rise), {@code Tr} (text rendering mode: fill / stroke /
* invisible; clipping modes 4-7 fall back to their non-clipping counterpart)</li>
* <li>Text showing: {@code Tj}, {@code TJ}, {@code '}, {@code "}</li>
* <li>Marked content / compatibility (no-op): {@code BMC}, {@code BDC},
* {@code EMC}, {@code MP}, {@code DP}, {@code BX}, {@code EX}</li>
Expand Down Expand Up @@ -900,6 +901,9 @@ private void dispatch(String op, List<PdfObject> 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;
Expand Down Expand Up @@ -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.
Expand All @@ -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}).
*
* <p>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.</p>
*/
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) {
Expand Down Expand Up @@ -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() {
}
Expand All @@ -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.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &mdash; 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<String> 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
Expand Down
Loading