001: /*
002: * Licensed to the Apache Software Foundation (ASF) under one or more
003: * contributor license agreements. See the NOTICE file distributed with
004: * this work for additional information regarding copyright ownership.
005: * The ASF licenses this file to You under the Apache License, Version 2.0
006: * (the "License"); you may not use this file except in compliance with
007: * the License. You may obtain a copy of the License at
008: *
009: * http://www.apache.org/licenses/LICENSE-2.0
010: *
011: * Unless required by applicable law or agreed to in writing, software
012: * distributed under the License is distributed on an "AS IS" BASIS,
013: * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014: * See the License for the specific language governing permissions and
015: * limitations under the License.
016: */
017: package org.apache.cocoon.reading;
018:
019: import java.awt.Color;
020: import java.awt.Font;
021: import java.awt.FontMetrics;
022: import java.awt.Graphics2D;
023: import java.awt.Image;
024: import java.awt.RenderingHints;
025: import java.awt.geom.Rectangle2D;
026: import java.awt.image.BufferedImage;
027: import java.awt.image.WritableRaster;
028: import java.io.ByteArrayOutputStream;
029: import java.io.IOException;
030: import java.lang.reflect.Field;
031: import java.util.Random;
032:
033: import com.sun.image.codec.jpeg.JPEGCodec;
034: import com.sun.image.codec.jpeg.JPEGEncodeParam;
035: import com.sun.image.codec.jpeg.JPEGImageEncoder;
036:
037: /**
038: * <p>The {@link CaptchaReader} is a simple tool generating JPEG images for the text
039: * supplied as its source in a way so that it's hard to parse automatically.</p>
040: *
041: * <p><i>CAPTCHA</i> means quite literally <i>Completely Automated Public Turing
042: * Test to Tell Computers and Humans Apart</i> and one of the best resources on
043: * this can be found at the <a href="http://www.captcha.net/">Carnegie Mellon
044: * School of Computer Science CAPTCHA project.</a>.
045: *
046: * <p>This reader creates very simple <i>CAPTCHAs</i> from within a Cocoon pipeline,
047: * enabling quick and safe end-user presence identificat. As an example, look at the
048: * following pipeline snippet:</p>
049: *
050: * <pre>
051: * <map:match pattern="*">
052: * <map:read type="captcha" src="{1}"/>
053: * </map:match>
054: * </pre>
055: *
056: * <p>The example will produce an image containing the text in <code>{1}</code>
057: * "warped" or "bent" in a way similar to the Adobe® Photoshop® "Wave"
058: * filter plugin.</p>
059: *
060: * <p>Few pipeline parameters control the operation of the {@link CaptchaReader}
061: * (this component is not configurable):</p>
062: *
063: * <ul>
064: * <li><code>width</code>: the width of the image to generate (default: 100).</li>
065: * <li><code>height</code>: the height of the image to generate (default: 50).</li>
066: * <li><code>foreground</code>: the text foreground color (default: random).</li>
067: * <li><code>background</code>: the image background color (default: white).</li>
068: * <li><code>font</code>: the font to use for the text (default: serif).</li>
069: * <li><code>scale</code>: the scaling factor for interim images (default: 5).</li>
070: * <li><code>amount</code>: the amount of text warping to apply (default: 1).</li>
071: * <li><code>quality</code>: the JPEG encoding quality (default: 0.75).</li>
072: * </ul>
073: *
074: * <p>Note that when the <code>foreground</code> parameter is not specified, the
075: * color used to write the text will be randomly chosen in a way that it contrasts
076: * well with the background color to avoid problems of illegible text.</p>
077: *
078: * <p>Both the <code>foreground</code> and <code>background</code> parameters accept
079: * strings in the format specified by {@link Color#decode(String)} (for example
080: * <code>fff</code>, or <code>0099CC</code>) or one of the field names of the
081: * {@link Color} class (for example {@link Color#BLACK BLACK} or {@link Color#cyan
082: * cyan} ...).</p>
083: *
084: * <p>The <code>scale</code> parameter controls how much the specified size should
085: * be scaled while processing the interim images: the bigger the scaling factor, the
086: * better the image quality, but also the memory used while generating the final
087: * image will be bigger. In other words, use with care.</p>
088: *
089: * <p>The <code>amount</code> parameter is interpreted as a floating point number
090: * and must be greater than zero. This controls how much text should be warped, and
091: * normally a value of <code>1</code> produce quite-good warping. Increasing (or
092: * decreasing) this value will produce more (ore less) warping.</p>
093: *
094: * <p>Remember that in no way the {@link CaptchaReader} claims to be able to
095: * generate "unbreakable" text (that will be impossible), and improvements to the
096: * algorithm are welcome.</p>
097: *
098: */
099: public class CaptchaReader extends AbstractReader {
100:
101: /** <p>A unique {@link Random} instance to use.</p> */
102: private static final Random RANDOM = new Random();
103:
104: /**
105: * <p>The content type of the generated content: <code>image/jpeg</code>.</p>
106: *
107: * @return Always <code>image/jpeg</code>.
108: */
109: public String getMimeType() {
110: return "image/jpeg";
111: }
112:
113: /**
114: * <p>Return a {@link Color} instance from a specified parameter.</p>
115: *
116: * @param parameterName The name of the parameter whose to use as the color.
117: * @param defaultColor The default {@link Color} to return.
118: * @return the interpreted color or the default color specified.
119: */
120: private Color getColor(String parameterName, Color defaultColor) {
121: String colorString = this .parameters.getParameter(
122: parameterName, null);
123: if (colorString == null)
124: return defaultColor;
125: try {
126: return Color.decode(colorString);
127: } catch (Exception e1) {
128: try {
129: Field colorField = Color.class
130: .getDeclaredField(colorString);
131: return (Color) colorField.get(Color.class);
132: } catch (Exception e2) {
133: return defaultColor;
134: }
135: }
136: }
137:
138: private Graphics2D antialiasedGraphics(BufferedImage image) {
139: Graphics2D graphics = image.createGraphics();
140: graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
141: RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
142: return graphics;
143: }
144:
145: /**
146: * <p>Create an image containing the text specified as this reader source
147: * warped to avoid automatic interpretation.</p>
148: *
149: * @throws IOException if an I/O error occurred generating the image.
150: */
151: public void generate() throws IOException {
152:
153: /* Retrieve the current operational parameters from Cocoon's sitemap */
154: final int width = this .parameters.getParameterAsInteger(
155: "width", 100);
156: final int height = this .parameters.getParameterAsInteger(
157: "height", 50);
158: Color background = this .getColor("background", Color.white);
159: Color foreground = this .getColor("foreground", null);
160: if (foreground == null) {
161: int r = (RANDOM.nextInt(64) + 96 + background.getRed()) & 0x0ff;
162: int g = (RANDOM.nextInt(64) + 96 + background.getGreen()) & 0x0ff;
163: int b = (RANDOM.nextInt(64) + 96 + background.getBlue()) & 0x0ff;
164: foreground = new Color(r, g, b);
165: }
166: final String fontName = this .parameters.getParameter("font",
167: "serif");
168: final int scale = this .parameters.getParameterAsInteger(
169: "scale", 5);
170: final float amount = this .parameters.getParameterAsFloat(
171: "amount", 2);
172: final float quality = this .parameters.getParameterAsFloat(
173: "quality", 0.75F);
174: final String text = this .source;
175:
176: /* Create the final buffered image we will be writing to at the bottom */
177: final BufferedImage result = new BufferedImage(width, height,
178: BufferedImage.TYPE_INT_RGB);
179:
180: /* Starting with a size of 100, evaluate how big the real font should be */
181: final Font baseFont = new Font(fontName, Font.PLAIN, 100);
182: final Graphics2D graphics = this .antialiasedGraphics(result);
183: final FontMetrics metrics = graphics.getFontMetrics(baseFont);
184: final Rectangle2D tempSize = metrics.getStringBounds(text,
185: graphics);
186:
187: /* Evaluate the image size of the resulting image and prepare a ratio */
188: final double tempWidth = tempSize.getWidth()
189: + (2 * tempSize.getHeight());
190: final double tempHeight = (tempSize.getHeight() * (1 + amount));
191: final double ratioWidth = width * scale / tempWidth;
192: final double ratioHeight = height * scale / tempHeight;
193: final double ratio = ratioWidth < ratioHeight ? ratioWidth
194: : ratioHeight;
195: final Font font = baseFont.deriveFont((float) (100 * ratio));
196:
197: /* Evaluate the final size of the text to write */
198: final FontMetrics sourceMetrics = graphics.getFontMetrics(font);
199: final Rectangle2D size = sourceMetrics.getStringBounds(text,
200: graphics);
201: final double textWidth = size.getWidth();
202: final double textHeight = size.getHeight();
203:
204: /* Evaluate the final size of the interim images */
205: int scaledWidth = (int) (tempWidth * ratio);
206: int scaledHeight = (int) (tempHeight * ratio);
207:
208: /* Create a couple of images to write the plain string and the warped one */
209: BufferedImage source = new BufferedImage(scaledWidth,
210: scaledHeight, BufferedImage.TYPE_BYTE_GRAY);
211: BufferedImage warped = new BufferedImage(scaledWidth,
212: scaledHeight, BufferedImage.TYPE_INT_ARGB);
213:
214: /* Prepare the background and the font of the source image */
215: final Graphics2D sourceGraphics = this
216: .antialiasedGraphics(source);
217: sourceGraphics.setColor(Color.black);
218: sourceGraphics.fillRect(0, 0, scaledWidth, scaledHeight);
219: sourceGraphics.setFont(font);
220:
221: /* Write the string exactly in the middle of the source image */
222: float textX = (float) ((scaledWidth - textWidth) / 2);
223: float textY = (float) ((scaledHeight - textHeight) / 2);
224: sourceGraphics.setColor(Color.white);
225: sourceGraphics.drawString(text, textX, textY
226: + sourceMetrics.getAscent());
227:
228: /* Randomize displacement factors for sine-waves */
229: final int displaceTop = RANDOM.nextInt(scaledWidth);
230: final int displaceBtm = RANDOM.nextInt(scaledWidth);
231: final int displaceVer = RANDOM.nextInt(scaledHeight);
232:
233: /* Calculate the horizontal and vertical amplitude and wavelength of sines */
234: final double amplitHor = textHeight * amount / 4;
235: final double amplitVer = textHeight / 8;
236: final double t = (RANDOM.nextDouble() * textWidth / 2)
237: + (textWidth * 0.75);
238: final double b = (RANDOM.nextDouble() * textWidth / 2)
239: + (textWidth * 0.75);
240: final double wlenTop = textHeight > t ? textHeight : t;
241: final double wlenBtm = textHeight > b ? textHeight : b;
242:
243: /* Calculate the offsets for horizontal (top and bottom) sine waves */
244: final double offsetTop = amplitHor;
245: final double offsetBtm = scaledHeight - amplitHor;
246:
247: /* Prepare an array for vertical displacement sine wave */
248: final double vert[] = new double[scaledHeight];
249: for (int v = 0; v < scaledHeight; v++) {
250: vert[v] = Math.sin((Math.PI * (v + displaceVer))
251: / textHeight)
252: * amplitVer;
253: }
254:
255: /* Iterate all the target image pixels and render the distortion */
256: int x1 = Integer.MAX_VALUE;
257: int x2 = Integer.MIN_VALUE;
258: int y1 = Integer.MAX_VALUE;
259: int y2 = Integer.MIN_VALUE;
260: final WritableRaster sourceRaster = source.getRaster();
261: final WritableRaster warpedRaster = warped.getRaster();
262: final double src[] = new double[9];
263: final double col[] = new double[] { foreground.getRed(),
264: foreground.getGreen(), foreground.getBlue(), 0 };
265: for (int h = 0; h < scaledWidth; h++) {
266: final double baseTop = (Math.PI * (h + displaceTop))
267: / wlenTop;
268: final double baseBtm = (Math.PI * (h + displaceBtm))
269: / wlenBtm;
270: final double top = offsetTop + Math.sin(baseTop)
271: * amplitHor;
272: final double btm = offsetBtm - Math.sin(baseBtm)
273: * amplitHor;
274:
275: for (int v = 0; v < scaledHeight; v++) {
276: final double x = (h + vert[v]);
277: final double y = (v * ((btm - top) / scaledHeight))
278: + top;
279:
280: if ((y > 0) && (y < scaledHeight - 1) && (x > 0)
281: && (x < scaledWidth - 1)) {
282:
283: /* Retrieve the nine pixels around the source one */
284: sourceRaster.getPixels((int) (x - 1),
285: (int) (y - 1), 3, 3, src);
286:
287: /* Average their value (it's grayscale) to have a better warp */
288: double alpha = ((src[1] + src[3] + src[5] + src[7]) * 0.1)
289: + ((src[0] + src[2] + src[6] + src[8]) * 0.025)
290: + (src[4] * 0.5);
291:
292: /* Write the resultin pixel in the target image if necessary */
293: if (alpha > 0) {
294: col[3] = alpha;
295: warpedRaster.setPixel(h, v, col);
296: if (h < x1)
297: x1 = h;
298: if (h > x2)
299: x2 = h;
300: if (v < y1)
301: y1 = v;
302: if (v > y2)
303: y2 = v;
304: }
305: }
306: }
307: }
308:
309: /* Crop the image to the maximum extent of the warped text (if visible) */
310: source = null;
311: int xd = x2 - x1 + 1;
312: int yd = y2 - y1 + 1;
313: if ((xd > 1) && (yd > 1)) {
314: warped = warped.getSubimage(x1, y1, xd, yd);
315: }
316:
317: /* Rescale the cropped image to the required size */
318: Image image = warped.getScaledInstance(width, height,
319: Image.SCALE_SMOOTH);
320: graphics.setBackground(background);
321: graphics.setColor(background);
322: graphics.fillRect(0, 0, width, height);
323: graphics.setColor(foreground);
324: graphics.drawImage(image, 0, 0, null);
325: warped = null;
326:
327: /* Write the processed image as a JPEG image */
328: ByteArrayOutputStream buffer = new ByteArrayOutputStream();
329: JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(buffer);
330: JPEGEncodeParam param = encoder
331: .getDefaultJPEGEncodeParam(result);
332: param.setQuality(quality, true);
333: encoder.encode(result, param);
334: buffer.flush();
335: buffer.close();
336: this.out.write(buffer.toByteArray());
337: this.out.flush();
338: }
339: }
|