From 36b93df9865bc0f2857ec53be19e66a2b4f33052 Mon Sep 17 00:00:00 2001 From: Rudi Date: Tue, 25 Aug 2026 15:48:36 +0000 Subject: [PATCH] Antialiasing and fill fixes --- .../org/pubvm/j3270/ui/TerminalPanel.java | 73 ++- .../org/lib3270j/graphics/GocaConstants.java | 6 + .../org/lib3270j/graphics/GocaDecoder.java | 190 +++++--- .../org/lib3270j/graphics/GraphicsPlane.java | 414 ++++++++++++++---- .../datastream/DataStreamProcessorTest.java | 2 +- .../lib3270j/graphics/GocaDecoderTest.java | 220 ++++++++++ .../lib3270j/input/InputProcessorTest.java | 3 +- 7 files changed, 760 insertions(+), 148 deletions(-) diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java index 91cac5b..221d599 100644 --- a/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java @@ -1,6 +1,7 @@ package org.pubvm.j3270.ui; import org.lib3270j.Telnet3270Client; +import org.lib3270j.graphics.GocaConstants; import org.lib3270j.screen.ExtendedAttribute; import org.lib3270j.screen.ScreenBuffer; @@ -905,6 +906,63 @@ public class TerminalPanel extends JPanel { public void setClient(Telnet3270Client client) { this.client = client; + if (client != null) { + setupGraphicsPlaneRenderer(); + updateCellSize(); + } + } + + private void setupGraphicsPlaneRenderer() { + if (client != null && client.getGraphicsPlane() != null) { + client.getGraphicsPlane().setTextRenderer((plane, x, y, text, colorArgb, cw, ch, dir, angle) -> { + int[] rgb = plane.getRgbBuffer(); + int pw = plane.getCanvasWidth(); + int ph = plane.getCanvasHeight(); + if (rgb == null || pw <= 0 || ph <= 0) return; + + java.awt.image.BufferedImage img = new java.awt.image.BufferedImage( + pw, ph, java.awt.image.BufferedImage.TYPE_INT_ARGB + ); + img.setRGB(0, 0, pw, ph, rgb, 0, pw); + + Graphics2D g2 = img.createGraphics(); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + + int fontSize = (int) Math.round(ch * 0.95); + if (fontSize < 10) fontSize = 10; + Font f = (boldTerminalFont != null ? boldTerminalFont : terminalFont).deriveFont((float) fontSize); + g2.setFont(f); + FontMetrics fm = g2.getFontMetrics(); + int ascent = fm.getAscent(); + + g2.setColor(new Color(colorArgb, true)); + + double curX = x; + double curY = y; + for (int i = 0; i < text.length(); i++) { + String s = text.substring(i, i + 1); + int charW = fm.stringWidth(s); + int drawX = (int) Math.round(curX + Math.max(0, (cw - charW) / 2.0)); + int drawY = (int) Math.round(curY + ascent + Math.max(0, (ch - fm.getHeight()) / 2.0)); + g2.drawString(s, drawX, drawY); + + switch (dir) { + case GocaConstants.CD_TB: curY += ch; break; + case GocaConstants.CD_RL: curX -= cw; break; + case GocaConstants.CD_BT: curY -= ch; break; + case GocaConstants.CD_LR: + case GocaConstants.CD_DEFAULT: + default: + curX += cw; + break; + } + } + g2.dispose(); + img.getRGB(0, 0, pw, ph, rgb, 0, pw); + }); + } } @Override @@ -931,9 +989,12 @@ public class TerminalPanel extends JPanel { Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); - g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); - g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g2.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g2.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY); // Clear entire panel with background color g2.setColor(bgColor); @@ -962,7 +1023,11 @@ public class TerminalPanel extends JPanel { cachedGraphicsImage.setRGB(0, 0, gWidth, gHeight, rgb, 0, gWidth); lastGraphicsUpdateCount = currentUpdateCount; } - g2.drawImage(cachedGraphicsImage, ox, oy, gridW, gridH, null); + if (gWidth == gridW && gHeight == gridH) { + g2.drawImage(cachedGraphicsImage, ox, oy, null); + } else { + g2.drawImage(cachedGraphicsImage, ox, oy, gridW, gridH, null); + } } } diff --git a/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java b/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java index 31bc45f..0c5d05c 100644 --- a/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java +++ b/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java @@ -105,6 +105,12 @@ public final class GocaConstants { public static final int LW_NORMAL = 1; public static final int LW_THICK = 2; + // Character Precision (G_GSCC / 0x3B) + public static final int CP_DEFAULT = 0; + public static final int CP_STRING = 1; + public static final int CP_CHAR = 2; + public static final int CP_STROKE = 3; + // Fill Patterns (GSPT) public static final int PT_DEFAULT = 0; public static final int PT_D1 = 1; diff --git a/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java b/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java index 48a3315..0de7a98 100644 --- a/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java +++ b/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java @@ -34,6 +34,7 @@ public class GocaDecoder { private int charWidth = 9; private int charHeight = 16; private int charSet = 0; + private int charPrecision = GocaConstants.CP_STRING; private int arcParamP = 1; private int arcParamQ = 0; private int arcParamR = 0; @@ -47,6 +48,8 @@ public class GocaDecoder { private boolean areaFill = true; private final List areaPointsX = new ArrayList<>(); private final List areaPointsY = new ArrayList<>(); + private final List areaPolygons = new ArrayList<>(); + private int currentPolyPts = 0; // Image accumulation private boolean inImage = false; @@ -212,6 +215,7 @@ public class GocaDecoder { charDir = GocaConstants.CD_LR; charAngle = 0.0; charSet = 0; + charPrecision = GocaConstants.CP_STRING; inArea = false; areaDrawBoundary = true; areaFill = true; @@ -226,24 +230,30 @@ public class GocaDecoder { /** * Determines the total byte length of a GOCA drawing order starting at data[idx]. * - * IMPORTANT ARCHITECTURE NOTE: - * GOCA orders follow IBM GA23-0059 architecture rules: - * 1. 1-byte standalone orders (NOP, etc.) -> length 1. - * 2. Delimiter orders (GEAR, ENDSEGM, ENDPROLOGUE, GEIMG) -> 1 or 2 bytes (with 0x00 trailing byte). - * 3. Fixed 1-byte immediate operand orders (0x00..0x1F range: GSCOL, GSLT, GSLW, GSMS, GSMC, GSPS, GSBMX) -> 2 bytes. - * 4. Orders with opcode >= 0x20 (including GCALL 0x2A, GSCS 0x38, GSCD 0x3A, GSPT 0x28, GSMT 0x29, GBAR 0x68, - * GLINE 0xC1, GARC 0xC6, GCHST 0xC3, etc.) are self-defining with a 1-byte length field data[idx+1], - * making total length = payloadLen + 2. - * Never hardcode orders >= 0x20 to 2 bytes, as that desynchronizes the GOCA order stream. + * IMPORTANT ARCHITECTURE & PARSING SAFETY NOTE: + * GOCA orders follow IBM GA23-0059 and Host On-Demand (HOD) architecture rules: + * 1. 1-byte standalone orders (NOP, ERASE, etc.) -> length 1. + * 2. Delimiter orders (GEAR, ENDSEGM, ENDPROLOGUE, GEIMG, GPOP) -> 1 or 2 bytes (with 0x00 trailing byte). + * 3. Fixed 1-byte operand orders (0x00..0x1F range: GSCOL, GSLT, GSLW, GSMS, GSMC, GSPS, GSBMX) -> 2 bytes. + * 4. Flexible 1-byte attribute orders (GBAR 0x68, GSCS 0x38, GSCD 0x3A, GSCC 0x3B, GSMP 0x39, + * GSPT 0x28, GSMT 0x29, GSMS_SET 0x3C) can be transmitted either as: + * - Short 2-byte form: [opcode] [value] (e.g. GBAR with flags 0x80 -> '68 80') + * - Self-defining 3-byte form: [opcode] [length=0x01] [value] (e.g. '68 01 80') + * CRITICAL: NEVER allow 1-byte attribute orders (like GBAR 0x68 or GSCC 0x3B) to fall through to the + * variable-length formula `(data[idx + 1] & 0xFF) + 2`. If GBAR flags (e.g. 0x80 for boundary) are read as + * a length field, the decoder will skip 130 bytes, corrupting and skipping all subsequent drawing orders! + * 5. Self-defining orders with multi-byte payloads (GLINE 0xC1, GARC 0xC6, GCHST 0xC3, GRLINE 0xE1, etc.) + * have a 1-byte length byte at data[idx + 1], making total length = payloadLen + 2. */ private int getOrderLength(byte[] data, int idx, int end) { int order = data[idx] & 0xFF; - if (order == GocaConstants.G_NOP1 || order == 0xFF) { + if (order == GocaConstants.G_NOP1 || order == 0xFF || order == 0x00) { return 1; } if (order == GocaConstants.G_GEAR || order == GocaConstants.G_ENDSEGM || order == GocaConstants.G_ENDPROLOGUE || - order == GocaConstants.G_GEIMG || order == GocaConstants.G_GPOP) { + order == GocaConstants.G_GEIMG || order == GocaConstants.G_GPOP || + order == GocaConstants.G_GERASE) { return (idx + 1 < end && data[idx + 1] == 0x00) ? 2 : 1; } if (idx + 1 >= end) { @@ -259,7 +269,8 @@ public class GocaDecoder { // Flexible 1-byte attribute orders (support both short 2-byte or long 3-byte if len byte == 1) if (order == GocaConstants.G_GSPT || order == GocaConstants.G_GSMT || order == GocaConstants.G_GSCS || order == GocaConstants.G_GSCD || - order == GocaConstants.G_GBAR) { + order == GocaConstants.G_GSCC || order == GocaConstants.G_GSMP || + order == GocaConstants.G_GSMS_SET || order == GocaConstants.G_GBAR) { return (data[idx + 1] == 0x01 && idx + 2 < end) ? 3 : 2; } if (order == GocaConstants.G_GCALL) { @@ -578,10 +589,15 @@ public class GocaDecoder { idx += orderLen; break; } + case GocaConstants.G_GSCC: { // Set Character Precision (0x3B) + charPrecision = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); + if (charPrecision == 0) charPrecision = GocaConstants.CP_STRING; + idx += orderLen; + break; + } case 0x04: case GocaConstants.G_GSMX: case GocaConstants.G_GSFLW: - case GocaConstants.G_GSCC: case GocaConstants.G_GSMS_SET: case GocaConstants.G_GPOP: { idx += orderLen; @@ -594,10 +610,8 @@ public class GocaDecoder { } case GocaConstants.G_GBAR: { // Begin Area (0x68) int flags = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); - boolean drawBoundary = (flags & 0x80) != 0 || (flags == 0); - boolean fill = (flags == 0) || (flags & 0x40) != 0 || - (pattern >= 1 && pattern <= 14); - beginArea(drawBoundary, fill); + boolean drawBoundary = (flags & 0x80) != 0 || (flags & 0x40) != 0 || (flags == 0); + beginArea(drawBoundary); idx += orderLen; break; } @@ -814,22 +828,31 @@ public class GocaDecoder { } } - private void beginArea(boolean drawBoundary, boolean fill) { + private void beginArea(boolean drawBoundary) { this.inArea = true; this.areaDrawBoundary = drawBoundary; - this.areaFill = fill; + this.areaFill = true; this.fillColor = this.curColor; this.areaPointsX.clear(); this.areaPointsY.clear(); + this.areaPolygons.clear(); + this.currentPolyPts = 0; } private void endArea() { - if (!inArea || areaPointsX.size() < 3) { + if (!inArea) return; + if (currentPolyPts > 0) { + areaPolygons.add(currentPolyPts); + currentPolyPts = 0; + } + if (areaPointsX.size() < 3 || areaPolygons.isEmpty()) { inArea = false; areaPointsX.clear(); areaPointsY.clear(); + areaPolygons.clear(); return; } + int n = areaPointsX.size(); int[] px = new int[n]; int[] py = new int[n]; @@ -838,21 +861,49 @@ public class GocaDecoder { py[i] = plane.mapY(areaPointsY.get(i)); } - plane.fillArea(px, py, n, fillColor, areaFill ? pattern : GocaConstants.PT_EMPTY, - areaDrawBoundary, curColor, lineType, lineWidth, bgMix, bgColor); + int numPolys = areaPolygons.size(); + int[] polyCounts = new int[numPolys]; + for (int i = 0; i < numPolys; i++) { + polyCounts[i] = areaPolygons.get(i); + } + + plane.fillArea(px, py, n, polyCounts, numPolys, fillColor, + pattern, areaDrawBoundary, curColor, lineType, lineWidth, bgMix, bgColor); inArea = false; areaPointsX.clear(); areaPointsY.clear(); + areaPolygons.clear(); + } + + private void addAreaLineStart(int startX, int startY) { + if (!inArea) return; + int sz = areaPointsX.size(); + if (sz > 0 && currentPolyPts > 0) { + int lastX = areaPointsX.get(sz - 1); + int lastY = areaPointsY.get(sz - 1); + if (lastX != startX || lastY != startY) { + areaPolygons.add(currentPolyPts); + currentPolyPts = 0; + } + } + if (currentPolyPts == 0) { + areaPointsX.add(startX); + areaPointsY.add(startY); + currentPolyPts++; + } } private void addAreaPoint(int x, int y) { - if (inArea) { - int sz = areaPointsX.size(); - if (sz == 0 || areaPointsX.get(sz - 1) != x || areaPointsY.get(sz - 1) != y) { - areaPointsX.add(x); - areaPointsY.add(y); + if (!inArea) return; + int sz = areaPointsX.size(); + if (sz > 0 && currentPolyPts > 0) { + if (areaPointsX.get(sz - 1) == x && areaPointsY.get(sz - 1) == y) { + return; } } + areaPointsX.add(x); + areaPointsY.add(y); + currentPolyPts++; } private void beginImage(int x, int y, int w, int h) { @@ -897,7 +948,7 @@ public class GocaDecoder { trackPoint(startX, startY); if (inArea) { - addAreaPoint(startX, startY); + addAreaLineStart(startX, startY); } while (pos + 4 <= end) { @@ -910,8 +961,8 @@ public class GocaDecoder { if (inArea) { addAreaPoint(nextX, nextY); } else { - plane.drawLine(plane.mapX(startX), plane.mapY(startY), - plane.mapX(nextX), plane.mapY(nextY), + plane.drawLine(plane.mapXDouble(startX), plane.mapYDouble(startY), + plane.mapXDouble(nextX), plane.mapYDouble(nextY), curColor, lineType, lineWidth); } @@ -939,7 +990,7 @@ public class GocaDecoder { trackPoint(startX, startY); if (inArea) { - addAreaPoint(startX, startY); + addAreaLineStart(startX, startY); } while (pos + 2 <= end) { @@ -955,8 +1006,8 @@ public class GocaDecoder { if (inArea) { addAreaPoint(nextX, nextY); } else { - plane.drawLine(plane.mapX(startX), plane.mapY(startY), - plane.mapX(nextX), plane.mapY(nextY), + plane.drawLine(plane.mapXDouble(startX), plane.mapYDouble(startY), + plane.mapXDouble(nextX), plane.mapYDouble(nextY), curColor, lineType, lineWidth); } @@ -1011,11 +1062,13 @@ public class GocaDecoder { if (semiAxis1 < 1.0) semiAxis1 = 1.0; if (semiAxis2 < 1.0) semiAxis2 = 1.0; - // Map to pixel space - int rx = Math.abs(plane.mapX((int) Math.round(semiAxis1)) - plane.mapX(0)); - int ry = Math.abs(plane.mapY(0) - plane.mapY((int) Math.round(semiAxis2))); - if (rx <= 0) rx = Math.max(1, (int) Math.round(semiAxis1)); - if (ry <= 0) ry = Math.max(1, (int) Math.round(semiAxis2)); + // Map to pixel space with double precision + double cx = plane.mapXDouble(centerX); + double cy = plane.mapYDouble(centerY); + double rx = Math.abs(plane.mapXDouble(semiAxis1) - plane.mapXDouble(0)); + double ry = Math.abs(plane.mapYDouble(0) - plane.mapYDouble(semiAxis2)); + if (rx <= 0.0) rx = Math.max(1.0, semiAxis1); + if (ry <= 0.0) ry = Math.max(1.0, semiAxis2); // For partial arcs, determine start angle and sweep angle double startAngleDeg = 0.0; @@ -1037,14 +1090,14 @@ public class GocaDecoder { int sweepFrac = data[pos + 1] & 0xFF; sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0; if (curX != centerX || curY != centerY) { - double startRad = Math.atan2(plane.mapY(centerY) - plane.mapY(curY), plane.mapX(curX) - plane.mapX(centerX)); + double startRad = Math.atan2(plane.mapYDouble(centerY) - plane.mapYDouble(curY), plane.mapXDouble(curX) - plane.mapXDouble(centerX)); startAngleDeg = Math.toDegrees(startRad); if (startAngleDeg < 0) startAngleDeg += 360.0; } } else { // No sweep data — if we have a current point, start there and sweep full circle if (curX != centerX || curY != centerY) { - double startRad = Math.atan2(plane.mapY(centerY) - plane.mapY(curY), plane.mapX(curX) - plane.mapX(centerX)); + double startRad = Math.atan2(plane.mapYDouble(centerY) - plane.mapYDouble(curY), plane.mapXDouble(curX) - plane.mapXDouble(centerX)); startAngleDeg = Math.toDegrees(startRad); if (startAngleDeg < 0) startAngleDeg += 360.0; } @@ -1055,11 +1108,7 @@ public class GocaDecoder { trackPoint(centerX - (int) Math.round(semiAxis1), centerY - (int) Math.round(semiAxis2)); trackPoint(centerX + (int) Math.round(semiAxis1), centerY + (int) Math.round(semiAxis2)); - System.out.println("processArc: center=(" + centerX + "," + centerY + ") cur=(" + curX + "," + curY - + ") rx=" + rx + " ry=" + ry + " start=" + startAngleDeg + " sweep=" + sweepAngleDeg - + " isFull=" + isFull); - - plane.drawArc(plane.mapX(centerX), plane.mapY(centerY), rx, ry, startAngleDeg, sweepAngleDeg, + plane.drawArc(cx, cy, rx, ry, startAngleDeg, sweepAngleDeg, curColor, lineType, lineWidth, isFull); curX = centerX; @@ -1070,19 +1119,19 @@ public class GocaDecoder { int pos = off; int end = off + len; - List ptsX = new ArrayList<>(); - List ptsY = new ArrayList<>(); + List ptsX = new ArrayList<>(); + List ptsY = new ArrayList<>(); if (fromCurPos) { - ptsX.add(plane.mapX(curX)); - ptsY.add(plane.mapY(curY)); + ptsX.add(plane.mapXDouble(curX)); + ptsY.add(plane.mapYDouble(curY)); } while (pos + 4 <= end) { int x = readCoord(data, pos); int y = readCoord(data, pos + 2); - ptsX.add(plane.mapX(x)); - ptsY.add(plane.mapY(y)); + ptsX.add(plane.mapXDouble(x)); + ptsY.add(plane.mapYDouble(y)); curX = x; curY = y; pos += 4; @@ -1090,8 +1139,8 @@ public class GocaDecoder { if (ptsX.size() >= 2) { int n = ptsX.size(); - int[] px = new int[n]; - int[] py = new int[n]; + double[] px = new double[n]; + double[] py = new double[n]; for (int i = 0; i < n; i++) { px[i] = ptsX.get(i); py[i] = ptsY.get(i); @@ -1106,14 +1155,14 @@ public class GocaDecoder { if (fromCurPos) { trackPoint(curX, curY); - plane.drawMarker(plane.mapX(curX), plane.mapY(curY), markerType, markerSize, markerColor); + plane.drawMarker(plane.mapXDouble(curX), plane.mapYDouble(curY), markerType, markerSize, markerColor); } while (pos + 4 <= end) { int x = readCoord(data, pos); int y = readCoord(data, pos + 2); trackPoint(x, y); - plane.drawMarker(plane.mapX(x), plane.mapY(y), markerType, markerSize, markerColor); + plane.drawMarker(plane.mapXDouble(x), plane.mapYDouble(y), markerType, markerSize, markerColor); curX = x; curY = y; pos += 4; @@ -1137,8 +1186,8 @@ public class GocaDecoder { if (textLen <= 0) return; // IBM 3279 vector graphics base cell is 9x12 - int cw = charWidth > 0 ? (int) Math.round((double) charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10; - int ch = charHeight > 0 ? (int) Math.round((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 12.0)) : 14; + double cw = charWidth > 0 ? ((double) charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10.0; + double ch = charHeight > 0 ? ((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 12.0)) : 14.0; int totalW = textLen * (charWidth > 0 ? charWidth : 9); int totalH = (charHeight > 0 ? charHeight : 14); @@ -1149,20 +1198,24 @@ public class GocaDecoder { if (charSet != 0 && programSymbolManager != null) { for (int i = 0; i < textLen; i++) { int code = data[pos + i] & 0xFF; - int px = plane.mapX(startX); - int py = plane.mapY(startY) - ch; + double px = plane.mapXDouble(startX); + double py = plane.mapYDouble(startY) - ch; ProgramSymbolSet.SymbolSlot slot = programSymbolManager.getSymbol(charSet, code); if (slot != null) { int[] rgb = slot.getRgbPixels(curColor, 0); int symW = slot.getWidth(); int symH = slot.getHeight(); - for (int dy = 0; dy < ch; dy++) { - int sy = (dy * symH) / ch; - for (int dx = 0; dx < cw; dx++) { - int sx = (dx * symW) / cw; + int ipx = (int) Math.round(px); + int ipy = (int) Math.round(py); + int icw = (int) Math.round(cw); + int ich = (int) Math.round(ch); + for (int dy = 0; dy < ich; dy++) { + int sy = (dy * symH) / ich; + for (int dx = 0; dx < icw; dx++) { + int sx = (dx * symW) / icw; int pixelArgb = rgb[sy * symW + sx]; if ((pixelArgb >>> 24) != 0) { - plane.setPixel(px + dx, py + dy, pixelArgb); + plane.setPixel(ipx + dx, ipy + dy, pixelArgb); } } } @@ -1183,8 +1236,13 @@ public class GocaDecoder { } String text = new String(chars); - plane.drawVectorText(plane.mapX(startX), plane.mapY(startY) - ch, text, - curColor, cw, ch, charDir, charAngle); + if (charPrecision == GocaConstants.CP_STROKE) { + plane.drawVectorText(plane.mapXDouble(startX), plane.mapYDouble(startY) - ch, text, + curColor, cw, ch, charDir, charAngle); + } else { + plane.drawText(plane.mapXDouble(startX), plane.mapYDouble(startY) - ch, text, + curColor, cw, ch, charDir, charAngle); + } curX = startX + (textLen * (charWidth > 0 ? charWidth : 9)); curY = startY; diff --git a/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java b/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java index 72547d5..e338819 100644 --- a/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java +++ b/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java @@ -121,15 +121,32 @@ public class GraphicsPlane { return screenRows; } + /** + * Maps a 3179G / GOCA signed coordinate (centered at screen midpoint) to canvas pixel X as a double. + */ + public double mapXDouble(double gocaX) { + int nominalWidth = screenCols * 9; + int xMax = (nominalWidth - 1) / 2 + ((nominalWidth - 1) % 2 != 0 ? 1 : 0); + double nx = gocaX + xMax; + return (nx * canvasWidth) / (double) (nominalWidth > 0 ? nominalWidth : 1); + } + + /** + * Maps a 3179G / GOCA signed coordinate (centered at screen midpoint, bottom-up) to canvas pixel Y (top-down) as a double. + */ + public double mapYDouble(double gocaY) { + int nominalHeight = screenRows * 12; + int yMax = (nominalHeight - 1) / 2; + double ny = yMax - gocaY; + return (ny * canvasHeight) / (double) (nominalHeight > 0 ? nominalHeight : 1); + } + /** * Maps a 3179G / GOCA signed coordinate (centered at screen midpoint) to canvas pixel X. * Coordinate space is symmetric: -xMax to +xMax, where nominalWidth = cols * 9 (e.g. 720 for 80 cols). */ public int mapX(int gocaX) { - int nominalWidth = screenCols * 9; - int xMax = (nominalWidth - 1) / 2 + ((nominalWidth - 1) % 2 != 0 ? 1 : 0); - int nx = gocaX + xMax; - return (int) Math.round((double) nx * canvasWidth / nominalWidth); + return (int) Math.round(mapXDouble((double) gocaX)); } /** @@ -138,10 +155,7 @@ public class GraphicsPlane { * NOTE: Do not apply arbitrary offsets here. The GOCA coordinate system is 1:1 synchronized with host GDDM. */ public int mapY(int gocaY) { - int nominalHeight = screenRows * 12; - int yMax = (nominalHeight - 1) / 2; - int ny = yMax - gocaY; - return (int) Math.round((double) ny * canvasHeight / nominalHeight); + return (int) Math.round(mapYDouble((double) gocaY)); } /** @@ -167,20 +181,163 @@ public class GraphicsPlane { } /** - * Safely plots a pixel at (x, y). + * Safely plots a pixel at (x, y) with Porter-Duff source-over alpha blending. */ public synchronized void setPixel(int x, int y, int colorArgb) { if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) { - rgbBuffer[y * canvasWidth + x] = colorArgb; + int srcA = (colorArgb >>> 24) & 0xFF; + if (srcA == 0) return; + int idx = y * canvasWidth + x; + if (srcA == 255) { + rgbBuffer[idx] = colorArgb; + } else { + int dst = rgbBuffer[idx]; + int dstA = (dst >>> 24) & 0xFF; + if (dstA == 0) { + rgbBuffer[idx] = colorArgb; + } else { + int srcR = (colorArgb >>> 16) & 0xFF; + int srcG = (colorArgb >>> 8) & 0xFF; + int srcB = colorArgb & 0xFF; + + int dstR = (dst >>> 16) & 0xFF; + int dstG = (dst >>> 8) & 0xFF; + int dstB = dst & 0xFF; + + int invSrcA = 255 - srcA; + int outA = srcA + (dstA * invSrcA + 127) / 255; + int outR = (srcR * srcA + dstR * invSrcA + 127) / 255; + int outG = (srcG * srcA + dstG * invSrcA + 127) / 255; + int outB = (srcB * srcA + dstB * invSrcA + 127) / 255; + + rgbBuffer[idx] = ((outA & 0xFF) << 24) | ((outR & 0xFF) << 16) | ((outG & 0xFF) << 8) | (outB & 0xFF); + } + } hasContent = true; - updateCount++; + updateCount++; } } /** - * Draws an absolute or relative line using Bresenham's algorithm with line styles and widths. + * Plots a pixel with fractional alpha coverage (0.0 to 1.0) for anti-aliasing. + */ + public synchronized void setPixelCoverage(int x, int y, int colorRgb, double coverage) { + if (coverage <= 0.0) return; + int alpha = (int) Math.round(coverage * 255.0); + if (alpha > 255) alpha = 255; + if (alpha <= 0) return; + setPixel(x, y, (alpha << 24) | (colorRgb & 0x00FFFFFF)); + } + + /** + * Draws an anti-aliased line using Xiaolin Wu's algorithm with sub-pixel double coordinates. + */ + public synchronized void drawLine(double x0, double y0, double x1, double y1, int colorArgb, int lineType, int lineWidth) { + int color = (colorArgb != 0) ? (colorArgb & 0x00FFFFFF) : 0x00FFFFFF; + + if (lineType != GocaConstants.LT_SOLID && lineType != GocaConstants.LT_DEFAULT) { + drawStyledLine((int) Math.round(x0), (int) Math.round(y0), + (int) Math.round(x1), (int) Math.round(y1), + (0xFF << 24) | color, lineType, lineWidth); + return; + } + + // Special case: single point or zero-length line + if (Math.abs(x1 - x0) < 1e-5 && Math.abs(y1 - y0) < 1e-5) { + drawPixelWithThickness((int) Math.round(x0), (int) Math.round(y0), (0xFF << 24) | color, (lineWidth == GocaConstants.LW_THICK) ? 2 : 1); + return; + } + + boolean steep = Math.abs(y1 - y0) > Math.abs(x1 - x0); + if (steep) { + double tmp = x0; x0 = y0; y0 = tmp; + tmp = x1; x1 = y1; y1 = tmp; + } + if (x0 > x1) { + double tmp = x0; x0 = x1; x1 = tmp; + tmp = y0; y0 = y1; y1 = tmp; + } + + double dx = x1 - x0; + double dy = y1 - y0; + double gradient = (dx == 0.0) ? 1.0 : (dy / dx); + + // First endpoint + double xend = Math.round(x0); + double yend = y0 + gradient * (xend - x0); + double xgap = 1.0 - (x0 + 0.5 - Math.floor(x0 + 0.5)); + int xpxl1 = (int) xend; + int ypxl1 = (int) Math.floor(yend); + + if (steep) { + plotPixelWu(ypxl1, xpxl1, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); + plotPixelWu(ypxl1 + 1, xpxl1, color, (yend - Math.floor(yend)) * xgap, lineWidth); + } else { + plotPixelWu(xpxl1, ypxl1, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); + plotPixelWu(xpxl1, ypxl1 + 1, color, (yend - Math.floor(yend)) * xgap, lineWidth); + } + double intery = yend + gradient; + + // Second endpoint + xend = Math.round(x1); + yend = y1 + gradient * (xend - x1); + xgap = x1 + 0.5 - Math.floor(x1 + 0.5); + int xpxl2 = (int) xend; + int ypxl2 = (int) Math.floor(yend); + + if (steep) { + plotPixelWu(ypxl2, xpxl2, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); + plotPixelWu(ypxl2 + 1, xpxl2, color, (yend - Math.floor(yend)) * xgap, lineWidth); + } else { + plotPixelWu(xpxl2, ypxl2, color, (1.0 - (yend - Math.floor(yend))) * xgap, lineWidth); + plotPixelWu(xpxl2, ypxl2 + 1, color, (yend - Math.floor(yend)) * xgap, lineWidth); + } + + // Main anti-aliased stepping loop + if (steep) { + for (int x = xpxl1 + 1; x < xpxl2; x++) { + int y = (int) Math.floor(intery); + double frac = intery - y; + plotPixelWu(y, x, color, 1.0 - frac, lineWidth); + plotPixelWu(y + 1, x, color, frac, lineWidth); + intery += gradient; + } + } else { + for (int x = xpxl1 + 1; x < xpxl2; x++) { + int y = (int) Math.floor(intery); + double frac = intery - y; + plotPixelWu(x, y, color, 1.0 - frac, lineWidth); + plotPixelWu(x, y + 1, color, frac, lineWidth); + intery += gradient; + } + } + + hasContent = true; + updateCount++; + } + + private void plotPixelWu(int x, int y, int colorRgb, double brightness, int lineWidth) { + if (brightness <= 0.0) return; + if (lineWidth == GocaConstants.LW_THICK) { + setPixelCoverage(x, y, colorRgb, 1.0); + setPixelCoverage(x + 1, y, colorRgb, Math.min(1.0, brightness)); + setPixelCoverage(x, y + 1, colorRgb, Math.min(1.0, brightness)); + setPixelCoverage(x + 1, y + 1, colorRgb, Math.min(1.0, brightness * 0.7)); + } else { + // Perceptual gamma correction for crisp contrast on dark backgrounds + double b = Math.min(1.0, Math.pow(brightness, 0.75) * 1.15); + setPixelCoverage(x, y, colorRgb, b); + } + } + + /** + * Draws an absolute or relative line using anti-aliasing for smooth vectors. */ public synchronized void drawLine(int x1, int y1, int x2, int y2, int colorArgb, int lineType, int lineWidth) { + drawLine((double) x1, (double) y1, (double) x2, (double) y2, colorArgb, lineType, lineWidth); + } + + private void drawStyledLine(int x1, int y1, int x2, int y2, int colorArgb, int lineType, int lineWidth) { int color = (colorArgb != 0) ? colorArgb : 0xFFFFFFFF; int thickness = (lineWidth == GocaConstants.LW_THICK) ? 2 : 1; @@ -255,25 +412,25 @@ public class GraphicsPlane { } /** - * Draws a full or partial arc / ellipse. + * Draws a full or partial arc / ellipse with sub-pixel double precision and anti-aliasing. */ - public synchronized void drawArc(int cx, int cy, int rx, int ry, double startAngleDeg, double sweepAngleDeg, + public synchronized void drawArc(double cx, double cy, double rx, double ry, double startAngleDeg, double sweepAngleDeg, int colorArgb, int lineType, int lineWidth, boolean isFull) { - if (rx <= 0) rx = 1; - if (ry <= 0) ry = 1; + if (rx <= 0) rx = 1.0; + if (ry <= 0) ry = 1.0; - int numSteps = Math.max(24, Math.max(rx, ry) * 4); + int numSteps = (int) Math.max(36, Math.max(rx, ry) * 6); double startRad = Math.toRadians(startAngleDeg); double sweepRad = isFull ? (2.0 * Math.PI) : Math.toRadians(sweepAngleDeg); double stepRad = sweepRad / numSteps; - int prevX = (int) Math.round(cx + rx * Math.cos(startRad)); - int prevY = (int) Math.round(cy - ry * Math.sin(startRad)); + double prevX = cx + rx * Math.cos(startRad); + double prevY = cy - ry * Math.sin(startRad); for (int i = 1; i <= numSteps; i++) { double angle = startRad + i * stepRad; - int nextX = (int) Math.round(cx + rx * Math.cos(angle)); - int nextY = (int) Math.round(cy - ry * Math.sin(angle)); + double nextX = cx + rx * Math.cos(angle); + double nextY = cy - ry * Math.sin(angle); drawLine(prevX, prevY, nextX, nextY, colorArgb, lineType, lineWidth); prevX = nextX; prevY = nextY; @@ -282,10 +439,15 @@ public class GraphicsPlane { updateCount++; } + public synchronized void drawArc(int cx, int cy, int rx, int ry, double startAngleDeg, double sweepAngleDeg, + int colorArgb, int lineType, int lineWidth, boolean isFull) { + drawArc((double) cx, (double) cy, (double) rx, (double) ry, startAngleDeg, sweepAngleDeg, colorArgb, lineType, lineWidth, isFull); + } + /** - * Draws a Fillet (spline / curve approximation across control points). + * Draws a Fillet (spline / curve approximation across control points) with sub-pixel precision. */ - public synchronized void drawFillet(int[] px, int[] py, int numPoints, int colorArgb, int lineType, int lineWidth) { + public synchronized void drawFillet(double[] px, double[] py, int numPoints, int colorArgb, int lineType, int lineWidth) { if (px == null || py == null || numPoints < 2) return; if (numPoints == 2) { @@ -293,8 +455,8 @@ public class GraphicsPlane { return; } - int prevX = px[0]; - int prevY = py[0]; + double prevX = px[0]; + double prevY = py[0]; for (int i = 0; i < numPoints - 1; i++) { double p0x = (i == 0) ? px[0] : (px[i - 1] + px[i]) / 2.0; @@ -304,23 +466,32 @@ public class GraphicsPlane { double p2x = (i == numPoints - 2) ? px[numPoints - 1] : (px[i] + px[i + 1]) / 2.0; double p2y = (i == numPoints - 2) ? py[numPoints - 1] : (py[i] + py[i + 1]) / 2.0; - int steps = 20; + int steps = 30; for (int s = 1; s <= steps; s++) { double t = (double) s / steps; double oneMinusT = 1.0 - t; double bx = oneMinusT * oneMinusT * p0x + 2.0 * oneMinusT * t * p1x + t * t * p2x; double by = oneMinusT * oneMinusT * p0y + 2.0 * oneMinusT * t * p1y + t * t * p2y; - int nextX = (int) Math.round(bx); - int nextY = (int) Math.round(by); - drawLine(prevX, prevY, nextX, nextY, colorArgb, lineType, lineWidth); - prevX = nextX; - prevY = nextY; + drawLine(prevX, prevY, bx, by, colorArgb, lineType, lineWidth); + prevX = bx; + prevY = by; } } hasContent = true; updateCount++; } + public synchronized void drawFillet(int[] px, int[] py, int numPoints, int colorArgb, int lineType, int lineWidth) { + if (px == null || py == null || numPoints < 2) return; + double[] dpx = new double[numPoints]; + double[] dpy = new double[numPoints]; + for (int i = 0; i < numPoints; i++) { + dpx[i] = px[i]; + dpy[i] = py[i]; + } + drawFillet(dpx, dpy, numPoints, colorArgb, lineType, lineWidth); + } + /** * Fills a closed polygon area with a solid color or hatching pattern. */ @@ -335,13 +506,24 @@ public class GraphicsPlane { public synchronized void fillArea(int[] px, int[] py, int numPoints, int fillColorArgb, int pattern, boolean drawBoundary, int boundaryColorArgb, int lineType, int lineWidth, int bgMix, int bgColorArgb) { + fillArea(px, py, numPoints, null, 1, fillColorArgb, pattern, drawBoundary, boundaryColorArgb, lineType, lineWidth, bgMix, bgColorArgb); + } + + /** + * Fills an area containing one or more closed polygon subpaths with a solid color or hatching pattern, + * using the even-odd fill rule across all subpath contours. + */ + public synchronized void fillArea(int[] px, int[] py, int numPoints, int[] polyCounts, int numPolys, + int fillColorArgb, int pattern, + boolean drawBoundary, int boundaryColorArgb, int lineType, int lineWidth, + int bgMix, int bgColorArgb) { if (px == null || py == null || numPoints < 3) return; int fill = (fillColorArgb != 0) ? fillColorArgb : 0xFFFFFFFF; int bg = bgColorArgb; if (pattern != GocaConstants.PT_EMPTY) { - // Find polygon vertical bounds + // Find polygon vertical bounds across all points int minY = py[0]; int maxY = py[0]; for (int i = 1; i < numPoints; i++) { @@ -356,13 +538,25 @@ public class GraphicsPlane { for (int y = minY; y <= maxY; y++) { nodeX.clear(); - int j = numPoints - 1; - for (int i = 0; i < numPoints; i++) { - if ((py[i] < y && py[j] >= y) || (py[j] < y && py[i] >= y)) { - int x = px[i] + (int) Math.round((double) (y - py[i]) / (py[j] - py[i]) * (px[j] - px[i])); - nodeX.add(x); + int offset = 0; + int polyCount = (polyCounts != null && numPolys > 0) ? numPolys : 1; + for (int p = 0; p < polyCount; p++) { + int pLen = (polyCounts != null && p < polyCounts.length) ? polyCounts[p] : numPoints; + if (pLen >= 3) { + int j = pLen - 1; + for (int i = 0; i < pLen; i++) { + int yi = py[offset + i]; + int yj = py[offset + j]; + int xi = px[offset + i]; + int xj = px[offset + j]; + if ((yi < y && yj >= y) || (yj < y && yi >= y)) { + int x = xi + (int) Math.round((double) (y - yi) / (yj - yi) * (xj - xi)); + nodeX.add(x); + } + j = i; + } } - j = i; + offset += pLen; } Collections.sort(nodeX); @@ -373,7 +567,7 @@ public class GraphicsPlane { int rightX = Math.min(canvasWidth - 1, nodeX.get(i + 1)); for (int x = leftX; x <= rightX; x++) { - if (pattern == GocaConstants.PT_SOLID || pattern >= 16) { + if (pattern == GocaConstants.PT_SOLID || pattern == 0 || pattern >= 16) { setPixel(x, y, fill); } else { int b = patRows[y & 7] & 0xFF; @@ -389,9 +583,23 @@ public class GraphicsPlane { } if (drawBoundary && boundaryColorArgb != 0) { - for (int i = 0; i < numPoints; i++) { - int next = (i + 1) % numPoints; - drawLine(px[i], py[i], px[next], py[next], boundaryColorArgb, lineType, lineWidth); + int offset = 0; + int polyCount = (polyCounts != null && numPolys > 0) ? numPolys : 1; + for (int p = 0; p < polyCount; p++) { + int pLen = (polyCounts != null && p < polyCounts.length) ? polyCounts[p] : numPoints; + if (pLen >= 2) { + for (int i = 0; i < pLen - 1; i++) { + drawLine((double) px[offset + i], (double) py[offset + i], + (double) px[offset + i + 1], (double) py[offset + i + 1], + boundaryColorArgb, lineType, lineWidth); + } + if (pLen >= 3 && (px[offset] != px[offset + pLen - 1] || py[offset] != py[offset + pLen - 1])) { + drawLine((double) px[offset + pLen - 1], (double) py[offset + pLen - 1], + (double) px[offset], (double) py[offset], + boundaryColorArgb, lineType, lineWidth); + } + } + offset += pLen; } } hasContent = true; @@ -399,11 +607,11 @@ public class GraphicsPlane { } /** - * Draws a GOCA marker symbol (+, x, diamond, square, star, dot, circle). + * Draws a GOCA marker symbol (+, x, diamond, square, star, dot, circle) with sub-pixel precision. */ - public synchronized void drawMarker(int x, int y, int markerType, int size, int colorArgb) { + public synchronized void drawMarker(double x, double y, int markerType, int size, int colorArgb) { int color = (colorArgb != 0) ? colorArgb : 0xFFFFFFFF; - int s = Math.max(3, size > 0 ? size : 5); + double s = Math.max(3.0, size > 0 ? (double) size : 5.0); switch (markerType) { case GocaConstants.MK_CROSS: // x @@ -429,8 +637,8 @@ public class GraphicsPlane { break; case GocaConstants.MK_6STAR: // 6-point star drawLine(x - s, y, x + s, y, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); - drawLine(x - s / 2, y - s, x + s / 2, y + s, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); - drawLine(x - s / 2, y + s, x + s / 2, y - s, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); + drawLine(x - s / 2.0, y - s, x + s / 2.0, y + s, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); + drawLine(x - s / 2.0, y + s, x + s / 2.0, y - s, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); break; case GocaConstants.MK_8STAR: // 8-point star drawLine(x - s, y, x + s, y, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); @@ -439,21 +647,17 @@ public class GraphicsPlane { drawLine(x - s, y + s, x + s, y - s, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); break; case GocaConstants.MK_SDIAMOND: // solid diamond - fillArea(new int[]{x, x + s, x, x - s}, new int[]{y - s, y, y + s, y}, 4, - color, GocaConstants.PT_SOLID, false, 0, 0, 0); + fillArea(new int[]{(int) Math.round(x), (int) Math.round(x + s), (int) Math.round(x), (int) Math.round(x - s)}, + new int[]{(int) Math.round(y - s), (int) Math.round(y), (int) Math.round(y + s), (int) Math.round(y)}, + 4, color, GocaConstants.PT_SOLID, false, 0, 0, 0); break; case GocaConstants.MK_SSQUARE: // solid square - fillArea(new int[]{x - s, x + s, x + s, x - s}, new int[]{y - s, y - s, y + s, y + s}, 4, - color, GocaConstants.PT_SOLID, false, 0, 0, 0); + fillArea(new int[]{(int) Math.round(x - s), (int) Math.round(x + s), (int) Math.round(x + s), (int) Math.round(x - s)}, + new int[]{(int) Math.round(y - s), (int) Math.round(y - s), (int) Math.round(y + s), (int) Math.round(y + s)}, + 4, color, GocaConstants.PT_SOLID, false, 0, 0, 0); break; case GocaConstants.MK_DOT: // dot - for (int dy = -2; dy <= 2; dy++) { - for (int dx = -2; dx <= 2; dx++) { - if (dx * dx + dy * dy <= 4) { - setPixel(x + dx, y + dy, color); - } - } - } + drawArc(x, y, 2.0, 2.0, 0.0, 360.0, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL, true); break; case GocaConstants.MK_CIRCLE: // circle default: @@ -464,6 +668,10 @@ public class GraphicsPlane { updateCount++; } + public synchronized void drawMarker(int x, int y, int markerType, int size, int colorArgb) { + drawMarker((double) x, (double) y, markerType, size, colorArgb); + } + private static final int[] VSS_OFFSETS = new int[256]; static { Arrays.fill(VSS_OFFSETS, -1); @@ -481,18 +689,54 @@ public class GraphicsPlane { } } + @FunctionalInterface + public interface TextRenderer { + void drawText(GraphicsPlane plane, double x, double y, String text, int colorArgb, + double cellWidth, double cellHeight, int dir, double angle); + } + + private TextRenderer textRenderer; + + public void setTextRenderer(TextRenderer renderer) { + this.textRenderer = renderer; + } + + public TextRenderer getTextRenderer() { + return this.textRenderer; + } + /** - * Draws stroked vector text using IBM Vector Symbol Set (VSS). + * Draws character text (using pluggable TextRenderer or fallback vector font). */ - public synchronized void drawVectorText(int x, int y, String text, int colorArgb, - int cellWidth, int cellHeight, int dir, double angle) { + public synchronized void drawText(double x, double y, String text, int colorArgb, + double cellWidth, double cellHeight, int dir, double angle) { + if (text == null || text.isEmpty()) return; + if (textRenderer != null) { + textRenderer.drawText(this, x, y, text, colorArgb, cellWidth, cellHeight, dir, angle); + hasContent = true; + updateCount++; + } else { + drawVectorText(x, y, text, colorArgb, cellWidth, cellHeight, dir, angle); + } + } + + public synchronized void drawText(int x, int y, String text, int colorArgb, + int cellWidth, int cellHeight, int dir, double angle) { + drawText((double) x, (double) y, text, colorArgb, (double) cellWidth, (double) cellHeight, dir, angle); + } + + /** + * Draws stroked vector text using IBM Vector Symbol Set (VSS) with sub-pixel anti-aliasing. + */ + public synchronized void drawVectorText(double x, double y, String text, int colorArgb, + double cellWidth, double cellHeight, int dir, double angle) { if (text == null || text.isEmpty()) return; int color = (colorArgb != 0) ? colorArgb : 0xFFFFFFFF; - int curX = x; - int curY = y; - int cw = cellWidth > 0 ? cellWidth : 12; - int ch = cellHeight > 0 ? cellHeight : 20; + double curX = x; + double curY = y; + double cw = cellWidth > 0 ? cellWidth : 12.0; + double ch = cellHeight > 0 ? cellHeight : 20.0; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); @@ -513,7 +757,12 @@ public class GraphicsPlane { updateCount++; } - private void drawVssChar(int x, int y, char c, int color, int cw, int ch) { + public synchronized void drawVectorText(int x, int y, String text, int colorArgb, + int cellWidth, int cellHeight, int dir, double angle) { + drawVectorText((double) x, (double) y, text, colorArgb, (double) cellWidth, (double) cellHeight, dir, angle); + } + + private void drawVssChar(double x, double y, char c, int color, double cw, double ch) { int code = (int) c; if (code < VectorSymbolData.VSS_SYMBOL_START || code >= 256) { return; @@ -532,20 +781,33 @@ public class GraphicsPlane { int dataPtr = ptr + 2; if (numPoints >= 2) { - int prevVx = ((VectorSymbolData.vss_data[dataPtr] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 1] & 0xFF); - int prevVy = ((VectorSymbolData.vss_data[dataPtr + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 3] & 0xFF); - int prevPx = x + (int) Math.round(((double) prevVx / VectorSymbolData.VSS_WIDTH) * cw); - int prevPy = y + (int) Math.round(((double)(VectorSymbolData.VSS_HEIGHT - prevVy) / VectorSymbolData.VSS_HEIGHT) * ch); + double[] px = new double[numPoints]; + double[] py = new double[numPoints]; + int[] ipx = new int[numPoints]; + int[] ipy = new int[numPoints]; - for (int p = 1; p < numPoints; p++) { + for (int p = 0; p < numPoints; p++) { int vx = ((VectorSymbolData.vss_data[dataPtr + p * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 1] & 0xFF); int vy = ((VectorSymbolData.vss_data[dataPtr + p * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + p * 4 + 3] & 0xFF); - int px = x + (int) Math.round(((double) vx / VectorSymbolData.VSS_WIDTH) * cw); - int py = y + (int) Math.round(((double)(VectorSymbolData.VSS_HEIGHT - vy) / VectorSymbolData.VSS_HEIGHT) * ch); + px[p] = x + ((double) vx / VectorSymbolData.VSS_WIDTH) * cw; + py[p] = y + ((double) (VectorSymbolData.VSS_HEIGHT - vy) / VectorSymbolData.VSS_HEIGHT) * ch; + ipx[p] = (int) Math.round(px[p]); + ipy[p] = (int) Math.round(py[p]); + } - drawLine(prevPx, prevPy, px, py, color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); - prevPx = px; - prevPy = py; + // If contour is closed (e.g. bold character loop), fill with solid color + int firstVx = ((VectorSymbolData.vss_data[dataPtr] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 1] & 0xFF); + int firstVy = ((VectorSymbolData.vss_data[dataPtr + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + 3] & 0xFF); + int lastVx = ((VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 1] & 0xFF); + int lastVy = ((VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 2] & 0xFF) << 8) | (VectorSymbolData.vss_data[dataPtr + (numPoints - 1) * 4 + 3] & 0xFF); + + boolean isClosed = (numPoints >= 4) && (firstVx == lastVx) && (firstVy == lastVy); + if (isClosed) { + fillArea(ipx, ipy, numPoints, color, GocaConstants.PT_SOLID, false, 0, 0, 0); + } + + for (int p = 0; p < numPoints - 1; p++) { + drawLine(px[p], py[p], px[p + 1], py[p + 1], color, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); } } ptr += 2 + byteLen; diff --git a/lib3270j/src/test/java/org/lib3270j/datastream/DataStreamProcessorTest.java b/lib3270j/src/test/java/org/lib3270j/datastream/DataStreamProcessorTest.java index c951019..30e9a48 100644 --- a/lib3270j/src/test/java/org/lib3270j/datastream/DataStreamProcessorTest.java +++ b/lib3270j/src/test/java/org/lib3270j/datastream/DataStreamProcessorTest.java @@ -74,7 +74,7 @@ public class DataStreamProcessorTest { input.setLastAid(AID_ENTER); java.util.concurrent.atomic.AtomicReference sentData = new java.util.concurrent.atomic.AtomicReference<>(); - processor.setOutputCallback(sentData::set); + processor.setOutputSender(sentData::set); byte[] rbRecord = new byte[] { (byte) CMD_RB }; processor.processRecord(rbRecord, 0, rbRecord.length, true); diff --git a/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java b/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java index e50302b..52d9117 100644 --- a/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java +++ b/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java @@ -335,4 +335,224 @@ public class GocaDecoderTest { assertEquals(0x40, decoder.getCharSet()); assertTrue(plane.hasContent(), "Expected plane to have content after GCALL segment execution"); } + + @Test + public void testAntialiasedLineRendering() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + plane.clear(); + int red = 0xFFFF0000; + + // Draw a diagonal line with Xiaolin Wu anti-aliasing + plane.drawLine(10.0, 10.0, 50.0, 30.0, red, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL); + assertTrue(plane.hasContent()); + + int[] buffer = plane.getRgbBuffer(); + boolean hasIntermediateAlpha = false; + int nonZeroPixels = 0; + + for (int y = 0; y < 100; y++) { + for (int x = 0; x < 100; x++) { + int pixel = buffer[y * 100 + x]; + if (pixel != 0) { + nonZeroPixels++; + int alpha = (pixel >>> 24) & 0xFF; + int r = (pixel >>> 16) & 0xFF; + assertEquals(255, r, "Red channel must be preserved"); + if (alpha > 0 && alpha < 255) { + hasIntermediateAlpha = true; + } + } + } + } + + assertTrue(nonZeroPixels > 30, "Expected non-zero pixels along the line"); + assertTrue(hasIntermediateAlpha, "Expected Xiaolin Wu anti-aliasing to produce fractional alpha coverage"); + } + + @Test + public void testSubpixelArcAndAlphaBlending() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + plane.clear(); + int green = 0xFF00FF00; + + plane.drawArc(50.0, 50.0, 30.0, 30.0, 0.0, 360.0, green, GocaConstants.LT_SOLID, GocaConstants.LW_NORMAL, true); + assertTrue(plane.hasContent()); + + int[] buffer = plane.getRgbBuffer(); + boolean hasIntermediateAlpha = false; + int nonZeroPixels = 0; + + for (int p : buffer) { + if (p != 0) { + nonZeroPixels++; + int alpha = (p >>> 24) & 0xFF; + if (alpha > 0 && alpha < 255) { + hasIntermediateAlpha = true; + } + } + } + + assertTrue(nonZeroPixels > 50, "Expected arc pixels"); + assertTrue(hasIntermediateAlpha, "Expected anti-aliased arc edges with smooth alpha"); + } + + @Test + public void testVectorTextAntialiasedDrawing() { + GraphicsPlane plane = new GraphicsPlane(200, 100); + plane.clear(); + int yellow = 0xFFFFFF00; + + plane.drawVectorText(10.0, 10.0, "8% 1985 TAX", yellow, 12.0, 20.0, GocaConstants.CD_LR, 0.0); + assertTrue(plane.hasContent()); + + int[] buffer = plane.getRgbBuffer(); + boolean hasIntermediateAlpha = false; + int nonZeroPixels = 0; + + for (int p : buffer) { + if (p != 0) { + nonZeroPixels++; + int alpha = (p >>> 24) & 0xFF; + if (alpha > 0 && alpha < 255) { + hasIntermediateAlpha = true; + } + } + } + + assertTrue(nonZeroPixels > 40, "Expected stroked vector text pixels"); + assertTrue(hasIntermediateAlpha, "Expected anti-aliased vector text strokes with fractional alpha"); + } + + @Test + public void testTextRendererPrecisionSwitching() { + GraphicsPlane plane = new GraphicsPlane(200, 100); + GocaDecoder decoder = new GocaDecoder(plane); + + final boolean[] textRendererCalled = new boolean[1]; + plane.setTextRenderer((p, x, y, text, color, cw, ch, dir, angle) -> { + textRendererCalled[0] = true; + }); + + // Test String Precision (Default): GCHST (0xC3) with "TAX" + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GCHST); + out.write(0x07); // length = 7 (4 bytes pos + 3 bytes text) + out.write(0x00); out.write(0x00); // x = 0 + out.write(0x00); out.write(0x00); // y = 0 + out.write(0xE3); // 'T' in EBCDIC + out.write(0xC1); // 'A' in EBCDIC + out.write(0xE7); // 'X' in EBCDIC + + byte[] stream = out.toByteArray(); + decoder.decodeStream(stream, 0, stream.length); + + assertTrue(textRendererCalled[0], "Expected pluggable TextRenderer to be called for String precision"); + + // Now set Stroke precision (G_GSCC with 3): + textRendererCalled[0] = false; + ByteArrayOutputStream outStroke = new ByteArrayOutputStream(); + outStroke.write(GocaConstants.G_GSCC); + outStroke.write(0x01); + outStroke.write(GocaConstants.CP_STROKE); // 3 + outStroke.write(GocaConstants.G_GCHST); + outStroke.write(0x07); + outStroke.write(0x00); outStroke.write(0x00); + outStroke.write(0x00); outStroke.write(0x00); + outStroke.write(0xE3); outStroke.write(0xC1); outStroke.write(0xE7); + + byte[] strokeStream = outStroke.toByteArray(); + decoder.decodeStream(strokeStream, 0, strokeStream.length); + + assertFalse(textRendererCalled[0], "Expected drawVectorText (not textRenderer) when precision is CP_STROKE"); + assertTrue(plane.hasContent()); + } + + @Test + public void testThickLineRendering() { + GraphicsPlane plane = new GraphicsPlane(100, 100); + plane.clear(); + + plane.drawLine(10.0, 10.0, 50.0, 50.0, 0xFF00FF00, GocaConstants.LT_SOLID, GocaConstants.LW_THICK); + assertTrue(plane.hasContent()); + + int[] buffer = plane.getRgbBuffer(); + int nonZero = 0; + for (int p : buffer) { + if (p != 0) nonZero++; + } + assertTrue(nonZero > 60, "Expected thick line to occupy more pixels than standard 1px line"); + } + + @Test + public void testShortFormAttributeOrderParsing() { + GraphicsPlane plane = new GraphicsPlane(200, 200); + GocaDecoder decoder = new GocaDecoder(plane); + + // Sequence: GBAR (0x68) short form with 0x80 flag (boundary=true, fill=false), + // followed immediately by GLINE (0xC1 len=12 for 3 points) and GEAR (0x60) + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GBAR); + out.write(0x80); // Short 2-byte form with 0x80 flag! Must NOT be treated as length=128! + out.write(GocaConstants.G_GLINE); + out.write(0x0C); // 3 points = 12 bytes + out.write(0x00); out.write(0x00); + out.write(0x00); out.write(0x00); + out.write(0x00); out.write(0x32); + out.write(0x00); out.write(0x00); + out.write(0x00); out.write(0x32); + out.write(0x00); out.write(0x32); + out.write(GocaConstants.G_GEAR); + out.write(0x00); + + byte[] stream = out.toByteArray(); + decoder.decodeStream(stream, 0, stream.length); + + assertTrue(plane.hasContent(), "Expected GLINE and GEAR inside GBAR short-form 0x80 to be decoded properly"); + } + + @Test + public void testMultiPolygonAreaFilling() { + GraphicsPlane plane = new GraphicsPlane(200, 200); + GocaDecoder decoder = new GocaDecoder(plane); + + // Sequence: GBAR (0x68) short form 0x80 (bounded, always filled in GOCA) + // Polygon 1 (e.g. Letter 'T' bar): (10,10) to (30,10) to (30,20) to (10,20) to (10,10) + // Polygon 2 (e.g. Letter 'T' stem): (18,20) to (22,20) to (22,40) to (18,40) to (18,20) + // GEAR (0x60) + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GBAR); + out.write(0x80); + + // Polygon 1 + out.write(GocaConstants.G_GLINE); + out.write(0x14); // 5 points = 20 bytes + out.write(0x00); out.write(10); out.write(0x00); out.write(10); + out.write(0x00); out.write(30); out.write(0x00); out.write(10); + out.write(0x00); out.write(30); out.write(0x00); out.write(20); + out.write(0x00); out.write(10); out.write(0x00); out.write(20); + out.write(0x00); out.write(10); out.write(0x00); out.write(10); + + // Polygon 2 (disconnected start -> triggers new subpath) + out.write(GocaConstants.G_GLINE); + out.write(0x14); // 5 points = 20 bytes + out.write(0x00); out.write(18); out.write(0x00); out.write(20); + out.write(0x00); out.write(22); out.write(0x00); out.write(20); + out.write(0x00); out.write(22); out.write(0x00); out.write(40); + out.write(0x00); out.write(18); out.write(0x00); out.write(40); + out.write(0x00); out.write(18); out.write(0x00); out.write(20); + + out.write(GocaConstants.G_GEAR); + out.write(0x00); + + byte[] stream = out.toByteArray(); + decoder.decodeStream(stream, 0, stream.length); + + assertTrue(plane.hasContent()); + int[] buffer = plane.getRgbBuffer(); + int nonZero = 0; + for (int p : buffer) { + if (p != 0) nonZero++; + } + assertTrue(nonZero > 50, "Expected both filled subpath polygons to render filled pixels"); + } } diff --git a/lib3270j/src/test/java/org/lib3270j/input/InputProcessorTest.java b/lib3270j/src/test/java/org/lib3270j/input/InputProcessorTest.java index 82e3100..f949876 100644 --- a/lib3270j/src/test/java/org/lib3270j/input/InputProcessorTest.java +++ b/lib3270j/src/test/java/org/lib3270j/input/InputProcessorTest.java @@ -3,6 +3,7 @@ package org.lib3270j.input; import org.junit.jupiter.api.Test; import org.lib3270j.TerminalModel; import org.lib3270j.charset.EbcdicTranslator; +import org.lib3270j.datastream.DataStreamProcessor; import org.lib3270j.screen.ScreenBuffer; import org.lib3270j.telnet.TelnetFSM; import static org.junit.jupiter.api.Assertions.*; @@ -184,7 +185,7 @@ public class InputProcessorTest { // Let's verify DataStreamProcessor ReadModified behavior with the same buffer DataStreamProcessor dsp = new DataStreamProcessor(screen, translator); dsp.setInputProcessor(input); - dsp.setOutputCallback(sent::set); + dsp.setOutputSender(sent::set); byte[] rmRecord = new byte[] { (byte) CMD_RM }; dsp.processRecord(rmRecord, 0, rmRecord.length, true);