001: /*
002: * GeoTools - OpenSource mapping toolkit
003: * http://geotools.org
004: * (C) 2006, Geotools Project Managment Committee (PMC)
005: *
006: * This library is free software; you can redistribute it and/or
007: * modify it under the terms of the GNU Lesser General Public
008: * License as published by the Free Software Foundation; either
009: * version 2.1 of the License, or (at your option) any later version.
010: *
011: * This library is distributed in the hope that it will be useful,
012: * but WITHOUT ANY WARRANTY; without even the implied warranty of
013: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
014: * Lesser General Public License for more details.
015: */
016: package org.geotools.coverage.processing;
017:
018: import java.awt.Color;
019: import java.io.Serializable;
020: import java.util.Arrays;
021: import java.util.Collections;
022: import java.util.HashMap;
023: import java.util.HashSet;
024: import java.util.Iterator;
025: import java.util.Map;
026: import java.util.Set;
027: import javax.units.Unit;
028: import javax.units.ConversionException;
029:
030: import org.opengis.util.InternationalString;
031: import org.opengis.referencing.operation.MathTransform1D;
032: import org.opengis.referencing.operation.TransformException;
033:
034: import org.geotools.coverage.Category;
035: import org.geotools.coverage.GridSampleDimension;
036: import org.geotools.io.TableWriter;
037: import org.geotools.util.logging.Logging;
038: import org.geotools.util.NumberRange;
039: import org.geotools.util.MeasurementRange;
040: import org.geotools.resources.Utilities;
041: import org.geotools.resources.i18n.Errors;
042: import org.geotools.resources.i18n.ErrorKeys;
043: import org.geotools.resources.i18n.Vocabulary;
044: import org.geotools.resources.i18n.VocabularyKeys;
045: import org.geotools.resources.image.ColorUtilities;
046:
047: /**
048: * Colors associated to categories. This is the parameter type for the
049: * {@link org.geotools.coverage.processing.operation.Recolor} operation.
050: *
051: * @since 2.4
052: * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/library/coverage/src/main/java/org/geotools/coverage/processing/ColorMap.java $
053: * @version $Id: ColorMap.java 27848 2007-11-12 13:10:32Z desruisseaux $
054: * @author Martin Desruisseaux
055: *
056: * @see org.geotools.coverage.processing.operation.Recolor
057: *
058: * @todo We need to investigage if this object should be defined as an implementation of
059: * {@link org.geotools.styling.ColorMap}.
060: */
061: public class ColorMap implements Serializable {
062: /**
063: * For cross-version compatibility.
064: */
065: private static final long serialVersionUID = 1688030908496323012L;
066:
067: /**
068: * A special category name meaning "<cite>any quantitative value</cite>".
069: */
070: public static final CharSequence ANY_QUANTITATIVE_CATEGORY = Vocabulary
071: .formatInternational(VocabularyKeys.ALL); // TODO: Find a better name.
072:
073: /**
074: * The colors to apply to categories. Keys are {@link String} objects.
075: * Values may be {@link Color} singletons or {@code Color[]} arrays.
076: * <p>
077: * The {@link #ANY_QUANTITATIVE_CATEGORY} key is replaced by {@code null} in
078: * order to avoid confusion with user-specified category with the exact name.
079: */
080: private Map/*<String,Object>*/colorMap;
081:
082: /**
083: * The range of values for quantitative categories. Values are {@link NumberRange} instances
084: * if the range is relative, or {@link MeasurementRange} if the range is geophysics.
085: * <p>
086: * The {@link #ANY_QUANTITATIVE_CATEGORY} key is replaced by {@code null} in
087: * order to avoid confusion with user-specified category with the exact name.
088: */
089: private Map/*<String,NumberRange>*/colorRanges;
090:
091: /**
092: * If {@code true}, the ARGB values corresponding to any {@linkplain Category category}
093: * <strong>not</strong> specified in this color map will be reset to the color specified
094: * by the category. The default value is {@code false}.
095: */
096: private boolean resetUnspecifiedColors;
097:
098: /**
099: * Creates an initially empty color map.
100: */
101: public ColorMap() {
102: }
103:
104: /**
105: * Creates a color map initialized to the specified color ramp to be applied on
106: * {@linkplain #ANY_QUANTITATIVE_CATEGORY any quantitative category}.
107: */
108: public ColorMap(final Color[] colors) {
109: setColors(ANY_QUANTITATIVE_CATEGORY, colors);
110: }
111:
112: /**
113: * Creates a color map initialized to the specified map.
114: *
115: * @param map A map of ({@linkplain Category#getName category name},
116: * {@linkplain Color colors}) pairs.
117: */
118: public ColorMap(
119: final Map/*<? extends CharSequence,Color[]>*/colorMap) {
120: for (final Iterator it = colorMap.entrySet().iterator(); it
121: .hasNext();) {
122: final Map.Entry entry = (Map.Entry) it.next();
123: setColors((CharSequence) entry.getKey(), (Color[]) entry
124: .getValue());
125: }
126: }
127:
128: /**
129: * Returns the unlocalized flavor of the given name
130: * (not to be confused with the default locale).
131: *
132: * @param category The {@linkplain Category#getName category name}.
133: * @return The unlocalized name, or {@code null}.
134: */
135: private static String unlocalized(final CharSequence name) {
136: if (name == ANY_QUANTITATIVE_CATEGORY) {
137: return null;
138: }
139: if (name instanceof InternationalString) {
140: return ((InternationalString) name).toString(null);
141: } else {
142: return name.toString();
143: }
144: }
145:
146: /**
147: * Applies colors to the given category.
148: *
149: * @param category The {@linkplain Category#getName name of the category}
150: * for which to set the colors.
151: * @param colors Colors to apply to the specified category, or {@code null}.
152: */
153: private void setColorObject(final CharSequence category,
154: final Object colors) {
155: final String name = unlocalized(category);
156: if (colors != null) {
157: if (colorMap == null) {
158: colorMap = new HashMap();
159: }
160: colorMap.put(name, colors);
161: } else if (colorMap != null) {
162: colorMap.remove(name);
163: if (colorMap.isEmpty()) {
164: colorMap = null; // For more accurate 'equals' implementation.
165: }
166: }
167: }
168:
169: /**
170: * Applies a uniform color to the given (usually <cite>qualitative</cite>) category.
171: *
172: * @param category The {@linkplain Category#getName name of the category}
173: * for which to set the color.
174: * @param color A uniform color to apply to the specified category, or {@code null}
175: * for removing the color mapping.
176: *
177: * @see #recolor
178: */
179: public void setColor(final CharSequence category, final Color color) {
180: setColorObject(category, color);
181: }
182:
183: /**
184: * Applies a color ramp to the given (usually <cite>quantitative</cite>) category.
185: * The color array may have any length; colors will be interpolated as needed.
186: *
187: * @param category The {@linkplain Category#getName name of the category} for which to set
188: * the colors, or {@link #ANY_QUANTITATIVE_CATEGORY} if the colors should apply to
189: * any quantitative category.
190: * @param colors The colors to apply to the specified category, or {@code null}
191: * or an empty array for removing the color mapping.
192: *
193: * @see #recolor
194: */
195: public void setColors(final CharSequence category,
196: final Color[] colors) {
197: final Object value;
198: if (colors != null) {
199: switch (colors.length) {
200: default:
201: value = colors.clone();
202: break;
203: case 1:
204: value = colors[0];
205: break;
206: case 0:
207: value = null;
208: break;
209: }
210: } else {
211: value = null;
212: }
213: setColorObject(category, value);
214: }
215:
216: /**
217: * Returns the color ramp for the given category.
218: *
219: * @param category The {@linkplain Category#getName category name}, or
220: * {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the colors to
221: * apply to any quantitative category.
222: * @return The color ramp, or {@code null} if none.
223: */
224: public Color[] getColors(final CharSequence category) {
225: if (colorMap == null) {
226: return null;
227: }
228: final String name = unlocalized(category);
229: Object colors = colorMap.get(name);
230: if (colors == null) {
231: if (name != null && category instanceof InternationalString) {
232: // Unlocalized name not found. Search using the localized flavor.
233: colors = getColors(category.toString());
234: if (colors == null) {
235: return null;
236: }
237: } else {
238: return null;
239: }
240: }
241: if (colors instanceof Color) {
242: return new Color[] { (Color) colors };
243: }
244: return (Color[]) ((Color[]) colors).clone();
245: }
246:
247: /**
248: * Sets a range of geophysics values for the color ramp associated with a quantitative category.
249: * For example if the category "<cite>Height</cite>" applies to geophysics values in the range
250: * [0..500] metres and if a range of [100..400] metres is defined as below:
251: *
252: * <blockquote><code>
253: * setRelativeRange("Height", new MeasurementRange(0, 100, SI.METRE));
254: * setColors("Height", myColorPalette);
255: * </code><blockquote>
256: *
257: * Then {@code myColorPalette} will applies to pixel values in the range [100..400] instead
258: * of [0..500]. This is typically used in order to augment the contrast in a range of values
259: * of special interest.
260: * <p>
261: * This method is exclusive with {@link #setRelativeRange}.
262: *
263: * @param category The {@linkplain Category#getName name of the category}
264: * for which to set the geophysics range.
265: * @param range The minimal and maximal values for the color ramp. A {@code null}
266: * value removes the range mapping.
267: *
268: * @see #recolor
269: */
270: public void setGeophysicsRange(final CharSequence category,
271: final MeasurementRange range) {
272: setRange(category, range);
273: }
274:
275: /**
276: * Sets a relative range of values for the color ramp associated to a quantitative category.
277: * For example if the category "<cite>Height</cite>" applies to pixel values in the range
278: * [0..200] and if a relative range of [20%..80%] is defined as below:
279: *
280: * <blockquote><code>
281: * setRelativeRange("Height", new NumberRange(20, 80));
282: * setColors("Height", myColorPalette);
283: * </code><blockquote>
284: *
285: * Then {@code myColorPalette} will applies to pixel values in the range [40..160] instead
286: * of [0..200]. This is typically used in order to augment the contrast in a range of values
287: * of special interest.
288: * <p>
289: * This method is exclusive with {@link #setGeophysicsRange}.
290: *
291: * @param category The {@linkplain Category#getName name of the category}
292: * for which to set the relative range.
293: * @param range The minimal and maximal relative values for the color ramp, as percentages
294: * between 0 and 100. A {@code null} value removes the range mapping.
295: *
296: * @see #recolor
297: */
298: public void setRelativeRange(final CharSequence category,
299: final NumberRange range) {
300: if (range instanceof MeasurementRange) {
301: // The MeasurementRange type is reserved for geophysics ranges.
302: throw new IllegalArgumentException(Errors.format(
303: ErrorKeys.ILLEGAL_ARGUMENT_$1, "range"));
304: }
305: setRange(category, range);
306: }
307:
308: /**
309: * Sets a relative or geophysics range.
310: * This method is exclusive with {@link #setGeophysicsRange}.
311: *
312: * @param category The {@linkplain Category#getName name of the category}
313: * for which to set the relative or geophysics range.
314: * @param range The minimal and maximal values for the color ramp.
315: */
316: private void setRange(final CharSequence category,
317: final NumberRange range) {
318: final String name = unlocalized(category);
319: if (range != null) {
320: if (colorRanges == null) {
321: colorRanges = new HashMap();
322: }
323: colorRanges.put(name, range);
324: } else if (colorRanges != null) {
325: colorRanges.remove(name);
326: if (colorRanges.isEmpty()) {
327: colorRanges = null; // For more accurate 'equals' implementation.
328: }
329: }
330: }
331:
332: /**
333: * Returns the range of geophysics values for the given category.
334: *
335: * @param category The {@linkplain Category#getName category name}, or
336: * {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the range to
337: * apply to any quantitative category.
338: * @return The geophysics range, or {@code null} if none.
339: */
340: public MeasurementRange getGeophysicsRange(
341: final CharSequence category) {
342: final NumberRange range = getRange(category);
343: return (range instanceof MeasurementRange) ? (MeasurementRange) range
344: : null;
345: }
346:
347: /**
348: * Returns the relative range of values for the given category.
349: *
350: * @param category The {@linkplain Category#getName category name}, or
351: * {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the relative
352: * range to apply to any quantitative category.
353: * @return The relative range, or {@code null} if none.
354: */
355: public NumberRange getRelativeRange(final CharSequence category) {
356: final NumberRange range = getRange(category);
357: return (range instanceof MeasurementRange) ? null : range;
358: }
359:
360: /**
361: * Returns the range of relative or geophysics values. If the returned range is an instance of
362: * {@link MeasurementRange}, then is is a {@linkplain #getGeophysicsRange geophysics range}.
363: * Otherwise it is a {@linkplain #getRelativeRange relative range}.
364: *
365: * @param category The {@linkplain Category#getName category name}, or
366: * {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the range to
367: * apply to any quantitative category.
368: * @return The relative or geophysics range, or {@code null} if none.
369: */
370: private NumberRange getRange(final CharSequence category) {
371: if (colorRanges == null) {
372: return null;
373: }
374: final String name = unlocalized(category);
375: NumberRange range = (NumberRange) colorRanges.get(name);
376: if (range == null) {
377: if (name != null && category instanceof InternationalString) {
378: // Unlocalized name not found. Search using the localized flavor.
379: range = (NumberRange) colorRanges.get(category
380: .toString());
381: }
382: }
383: return range;
384: }
385:
386: /**
387: * Returns the range of sample values for the given category, or {@code null} if none.
388: * This range is computed from the {@linkplain #getRange relative or geophysics range}.
389: *
390: * @param category The category for which to compute the range.
391: * @param units The category units, usually {@link GridSampleDimension#getUnits}.
392: * @return The range, or {@code null} if none. The lower index is always inclusive
393: * and the upper index is always exclusive.
394: */
395: private NumberRange getTargetRange(final Category category,
396: final Unit units) {
397: NumberRange scale = getRange(category.getName());
398: if (scale == null) {
399: if (category.isQuantitative()) {
400: scale = getRange(ANY_QUANTITATIVE_CATEGORY);
401: }
402: if (scale == null) {
403: return null;
404: }
405: }
406: double minimum = scale.getMinimum();
407: double maximum = scale.getMaximum();
408: boolean minIncluded = scale.isMinIncluded();
409: boolean maxIncluded = scale.isMaxIncluded();
410: if (scale instanceof MeasurementRange) {
411: try {
412: scale = ((MeasurementRange) scale).convertTo(units);
413: } catch (ConversionException e) {
414: Logging.unexpectedException(AbstractProcessor.LOGGER,
415: ColorMap.class, "recolor", e);
416: return null; // This is allowed by this method contract.
417: }
418: MathTransform1D tr = category.getSampleToGeophysics();
419: if (tr != null)
420: try {
421: tr = (MathTransform1D) tr.inverse();
422: minimum = tr.transform(minimum);
423: maximum = tr.transform(maximum);
424: } catch (TransformException e) {
425: Logging.unexpectedException(
426: AbstractProcessor.LOGGER, ColorMap.class,
427: "recolor", e);
428: return null; // This is allowed by this method contract.
429: }
430: } else {
431: final NumberRange range = category.getRange();
432: final double lower = range.getMinimum();
433: final double extent = range.getMaximum() - lower;
434: minimum = (minimum / 100) * extent + lower;
435: maximum = (maximum / 100) * extent + lower;
436: minIncluded &= range.isMinIncluded();
437: maxIncluded &= range.isMaxIncluded();
438: }
439: final int lower, upper;
440: if (minimum > maximum) {
441: lower = round(maximum, maxIncluded);
442: upper = round(minimum, !minIncluded);
443: } else {
444: lower = round(minimum, minIncluded);
445: upper = round(maximum, !maxIncluded);
446: }
447: return new NumberRange(lower, true, upper, false);
448: }
449:
450: /**
451: * Round the specified number to the {@linkplain Math#floor lower} or {@linkplain Math#ceil
452: * upper} value, depending if the value is inclusive or not. This method is appropriate for
453: * minimal range value. In order to apply it to the maximal range value, {@code included}
454: * must be replaced by {@code !included}.
455: */
456: private static int round(final double value, final boolean included) {
457: final double rounded = included ? Math.floor(value) : Math
458: .ceil(value);
459: int asInteger = (int) rounded;
460: if (!included && value == rounded) {
461: asInteger++;
462: }
463: return asInteger;
464: }
465:
466: /**
467: * If {@code true}, the ARGB values corresponding to any {@linkplain Category category}
468: * <strong>not</strong> specified in this color map will be reset to the color specified
469: * by the category. The default value is {@code false}.
470: */
471: public void setResetUnspecifiedColors(final boolean reset) {
472: resetUnspecifiedColors = reset;
473: }
474:
475: /**
476: * If {@code true}, the ARGB values corresponding to any {@linkplain Category category}
477: * <strong>not</strong> specified in this color map will be reset to the color specified
478: * by the category. The default value is {@code false}.
479: */
480: public boolean getResetUnspecifiedColors() {
481: return resetUnspecifiedColors;
482: }
483:
484: /**
485: * Applies to the specified sample dimension the colors given to this color map. This method
486: * iterates throug every {@linkplain Category categories} in the given sample dimension. For
487: * each category with a {@linkplain Category#getName name} matching one of the (<var>name</var>,
488: * <var>colors</var>) or (<var>name</var>, <var>range</var>) entries given to this color map,
489: * the {@link Category#recolor recolor} method is invoked on that category and the result
490: * inserted into a new sample dimension to be returned.
491: * <p>
492: * If the optional {@code ARGB} array is non-null, then the ARGB colors for recolorized
493: * categories will be written in this array. Only the elements with index in the
494: * {@linkplain Category#getRange category range} will be overwritten; other elements
495: * will not be modified.
496: * <p>
497: * <strong>NOTE:</strong> The {@linkplain #setGeophysicsRange geophysics} and
498: * {@linkplain #setRelativeRange relative} ranges are taken in account for the
499: * {@code ARGB} array only; they do not have impact on the categories to be
500: * included in the returned sample dimension.
501: *
502: * @param sampleDimension The sample dimension to recolorize.
503: * @param ARGB An optional array where to store the ARGB values of recolorized categories,
504: * or {@code null} if none.
505: * @return A new sample dimension, or {@code sampleDimension} if no color change were applied.
506: *
507: * @see Category#recolor
508: */
509: public GridSampleDimension recolor(
510: final GridSampleDimension sampleDimension, final int[] ARGB) {
511: final GridSampleDimension displayDimension = sampleDimension
512: .geophysics(false);
513: boolean changed = false;
514: final Category categories[] = (Category[]) displayDimension
515: .getCategories().toArray();
516: for (int i = 0; i < categories.length; i++) {
517: Category category = categories[i];
518: Color[] colors = getColors(category.getName());
519: if (colors == null) {
520: if (category.isQuantitative()) {
521: colors = getColors(ANY_QUANTITATIVE_CATEGORY);
522: }
523: if (colors == null && resetUnspecifiedColors) {
524: colors = category.getColors();
525: }
526: // 'colors' may still null, so we will need to check.
527: }
528: if (ARGB != null) {
529: final NumberRange range = category.getRange();
530: // TODO: Remove casts when we will be allowed to compile for J2SE 1.5.
531: int lower = ((Number) range.getMinValue()).intValue();
532: int upper = ((Number) range.getMaxValue()).intValue();
533: if (!range.isMinIncluded())
534: lower++;
535: if (range.isMaxIncluded())
536: upper++;
537: boolean outOfBounds = false;
538: if (lower < 0) {
539: lower = 0;
540: outOfBounds = true;
541: }
542: if (upper > ARGB.length) {
543: upper = ARGB.length;
544: outOfBounds = true;
545: }
546: if (outOfBounds) {
547: AbstractProcessor.LOGGER.warning(Errors.format(
548: ErrorKeys.VALUE_OUT_OF_BOUNDS_$3, category,
549: new Integer(0),
550: new Integer(ARGB.length - 1)));
551: }
552: if (upper <= lower) {
553: continue;
554: }
555: final NumberRange target = getTargetRange(category,
556: sampleDimension.getUnits());
557: if (target != null) {
558: if (colors == null) {
559: colors = category.getColors();
560: }
561: if (colors.length >= 2) {
562: assert target.isMinIncluded()
563: && !target.isMaxIncluded() : target;
564: final int lo = Math.max(lower, ((Number) target
565: .getMinValue()).intValue());
566: final int hi = Math.min(upper, ((Number) target
567: .getMaxValue()).intValue());
568: if (lo != lower || hi != upper) {
569: Arrays.fill(ARGB, lower, lo, colors[0]
570: .getRGB());
571: Arrays.fill(ARGB, hi, upper,
572: colors[colors.length - 1].getRGB());
573: lower = lo;
574: upper = hi;
575: }
576: }
577: } else if (colors == null) {
578: /*
579: * If there is no range to change (target == null) and no colors explicitly
580: * specified by the user (colors == null), then there is nothing to do.
581: */
582: continue;
583: }
584: ColorUtilities.expand(colors, ARGB, lower, upper);
585: } else if (colors == null) {
586: continue;
587: }
588: category = category.recolor(colors);
589: if (!categories[i].equals(category)) {
590: categories[i] = category;
591: changed = true;
592: }
593: }
594: if (!changed) {
595: return sampleDimension;
596: }
597: GridSampleDimension result = new GridSampleDimension(
598: displayDimension.getDescription(), categories,
599: displayDimension.getUnits());
600: if (sampleDimension != displayDimension) {
601: result = result.geophysics(true);
602: }
603: return result;
604: }
605:
606: /**
607: * Returns all category names declared in this color map, in alphabetical order.
608: * If the {@link #ANY_QUANTITATIVE_CATEGORY} special value is presents, it will
609: * appears last.
610: */
611: private CharSequence[] getCategoryNames() {
612: final Set/*<String>*/names;
613: if (colorMap != null) {
614: if (colorRanges != null) {
615: names = new HashSet(colorMap.keySet());
616: names.addAll(colorRanges.keySet());
617: } else {
618: names = colorMap.keySet();
619: }
620: } else {
621: if (colorRanges != null) {
622: names = colorRanges.keySet();
623: } else {
624: names = Collections.EMPTY_SET;
625: }
626: }
627: int count = names.size();
628: final CharSequence[] asArray = (CharSequence[]) names
629: .toArray(new CharSequence[count]);
630: for (int i = count; --i >= 0;) {
631: if (asArray[i] == null) {
632: System.arraycopy(asArray, i + 1, asArray, i, --count
633: - i);
634: asArray[count] = ANY_QUANTITATIVE_CATEGORY;
635: // We could stop the loop here since we should not have any additional
636: // null values. However we let the loop continue as a paranoiac check.
637: }
638: }
639: Arrays.sort(asArray, 0, count);
640: return asArray;
641: }
642:
643: /**
644: * Returns a hash code value for this color map.
645: */
646: //@Override
647: public int hashCode() {
648: return (int) serialVersionUID
649: ^ ((colorMap != null ? colorMap.hashCode() : 31) + 37 * (colorRanges != null ? colorRanges
650: .hashCode()
651: : 31));
652: }
653:
654: /**
655: * Compares this color map with the specified object for equality.
656: */
657: //@Override
658: public boolean equals(final Object object) {
659: if (object != null && getClass().equals(object.getClass())) {
660: final ColorMap that = (ColorMap) object;
661: return Utilities.equals(this .colorMap, that.colorMap)
662: && Utilities.equals(this .colorRanges,
663: that.colorRanges);
664: }
665: return false;
666: }
667:
668: /**
669: * Returns a string representation of this color map.
670: */
671: //@Override
672: public String toString() {
673: final CharSequence[] names = getCategoryNames();
674: final TableWriter writer = new TableWriter(null, 1);
675: for (int i = 0; i < names.length; i++) {
676: final CharSequence name = names[i];
677: writer.write(name.toString());
678: if (colorRanges != null) {
679: final NumberRange range = getRange(name);
680: if (range != null) {
681: writer.write(' ');
682: writer.write(range.toString());
683: if (!(range instanceof MeasurementRange)) {
684: writer.write('%');
685: }
686: }
687: }
688: writer.nextColumn();
689: writer.write(':');
690: writer.nextColumn();
691: final Color[] colors = getColors(name);
692: if (colors != null) {
693: final String message;
694: if (colors.length == 1) {
695: message = Integer.toHexString(colors[0].getRGB())
696: .toUpperCase();
697: } else {
698: message = Vocabulary.format(
699: VocabularyKeys.COLOR_COUNT_$1, new Integer(
700: ((Color[]) colors).length));
701: }
702: writer.write(message);
703: }
704: writer.nextLine();
705: }
706: return writer.toString();
707: }
708: }
|