001: /*
002: * uDig - User Friendly Desktop Internet GIS client http://udig.refractions.net (C) 2004,
003: * Refractions Research Inc. This library is free software; you can redistribute it and/or modify it
004: * under the terms of the GNU Lesser General Public License as published by the Free Software
005: * Foundation; version 2.1 of the License. This library is distributed in the hope that it will be
006: * useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
007: * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
008: */
009: package net.refractions.udig.project.ui.internal;
010:
011: import static net.refractions.udig.project.internal.provider.LayerItemProvider.GENERATED_ICON;
012: import static net.refractions.udig.project.internal.provider.LayerItemProvider.GENERATED_NAME;
013:
014: import java.io.IOException;
015: import java.util.LinkedList;
016: import java.util.NoSuchElementException;
017: import java.util.Set;
018: import java.util.concurrent.CopyOnWriteArraySet;
019:
020: import net.refractions.udig.catalog.IGeoResource;
021: import net.refractions.udig.catalog.IGeoResourceInfo;
022: import net.refractions.udig.project.internal.Layer;
023: import net.refractions.udig.project.internal.ProjectPackage;
024: import net.refractions.udig.project.internal.StyleBlackboard;
025: import net.refractions.udig.ui.ImageCache;
026: import net.refractions.udig.ui.graphics.Glyph;
027: import net.refractions.udig.ui.graphics.SLDs;
028:
029: import org.eclipse.core.runtime.IProgressMonitor;
030: import org.eclipse.core.runtime.IStatus;
031: import org.eclipse.core.runtime.Status;
032: import org.eclipse.core.runtime.jobs.Job;
033: import org.eclipse.emf.common.notify.Adapter;
034: import org.eclipse.emf.common.notify.Notification;
035: import org.eclipse.emf.common.notify.impl.AdapterImpl;
036: import org.eclipse.jface.resource.ImageDescriptor;
037: import org.eclipse.jface.viewers.ILabelDecorator;
038: import org.eclipse.jface.viewers.ILabelProviderListener;
039: import org.eclipse.jface.viewers.LabelProviderChangedEvent;
040: import org.eclipse.swt.graphics.Image;
041: import org.geotools.data.FeatureSource;
042: import org.geotools.data.wms.WebMapServer;
043: import org.geotools.feature.FeatureType;
044: import org.geotools.feature.GeometryAttributeType;
045: import org.geotools.styling.FeatureTypeStyle;
046: import org.geotools.styling.PointSymbolizer;
047: import org.geotools.styling.Rule;
048: import org.geotools.styling.Style;
049: import org.geotools.styling.Symbolizer;
050: import org.opengis.coverage.grid.GridCoverageReader;
051:
052: import com.vividsolutions.jts.geom.Geometry;
053: import com.vividsolutions.jts.geom.GeometryCollection;
054: import com.vividsolutions.jts.geom.LineString;
055: import com.vividsolutions.jts.geom.MultiLineString;
056: import com.vividsolutions.jts.geom.MultiPoint;
057: import com.vividsolutions.jts.geom.MultiPolygon;
058: import com.vividsolutions.jts.geom.Point;
059: import com.vividsolutions.jts.geom.Polygon;
060:
061: /**
062: * Generate glyph/title - fetch from WMS or derrive from StyleBlackboard.
063: * <p>
064: * This is a complete heavyweight decorator - there is no messing around with this one. It has its
065: * own thread, and will pay attention to events.
066: * </p>
067: * <p>
068: * Generated Content is placed in layer properties:
069: * <ul>
070: * <li>displayName:
071: * <li>displayGlyph:
072: * </ul>
073: * </p>
074: * <p>
075: * Generation only kicks in if getGlyph or getName return null.
076: * </p>
077: *
078: * @author jgarnett
079: * @since 0.7.0
080: */
081: public class LayerGeneratedGlyphDecorator implements ILabelDecorator {
082:
083: /**
084: * Queue of layers needing to be refreshed.
085: * <p>
086: * Does not allow duplicates to be added.
087: * </p>
088: */
089: LinkedList<Layer> queue = new LinkedList<Layer>() {
090: /** <code>serialVersionUID</code> field */
091: private static final long serialVersionUID = 3834874663317747760L;
092:
093: public void add(int index, Layer aLayer) {
094: if (!contains(aLayer))
095: super .add(index, aLayer);
096: }
097:
098: public boolean add(Layer aLayer) {
099: if (!contains(aLayer))
100: return super .add(aLayer);
101: return false;
102: }
103:
104: public void addFirst(Layer aLayer) {
105: if (!contains(aLayer))
106: super .addFirst(aLayer);
107:
108: }
109:
110: public void addLast(Layer aLayer) {
111: if (!contains(aLayer))
112: super .addFirst(aLayer);
113: }
114: };
115:
116: private volatile boolean disposed = false;
117:
118: /**
119: * Piccaso generates pcitures for layers in the queue.
120: * <p>
121: * This is the sole provider of dynamic artwork for layers. Piccaso will block contacting
122: * external servers and so on.
123: * </p>
124: * <p>
125: * If this gets to be a pain we may switch to the dutch school model, perhaps even
126: * impressionests based on a sample feature.
127: * </p>
128: * <p>
129: * Any artwork is provided as an ImageDescriptor using the key GENERATED_ICON. This will be
130: * turned into an Image by the decorateImage method as required.
131: * </p>
132: */
133: Job picasso = new Job(Messages.LayerGeneratedGlyphDecorator_jobName) {
134:
135: @SuppressWarnings("unchecked")
136: public IStatus run(IProgressMonitor monitor) {
137: Layer layer = null;
138: SERVICE: while (!disposed) {
139: synchronized (queue) {
140: if (queue.isEmpty()) {
141: return Status.OK_STATUS;
142: }
143:
144: try {
145: layer = queue.removeFirst();
146: if (!layer.eAdapters().contains(hack)) {
147: layer.eAdapters().add(hack);
148: }
149: } catch (NoSuchElementException noLayer) {
150: continue SERVICE;
151: }
152: }
153: try {
154: boolean notifyIcon = refreshIcon(layer);
155:
156: boolean notifyLabel = refreshLabel(layer);
157:
158: if (notifyIcon || notifyLabel) {
159: refresh(layer);
160: }
161: } catch (Throwable t) {
162: // must catch all icon errors or this thread will die :-P
163: }
164: }
165: return Status.OK_STATUS;
166:
167: }
168:
169: /**
170: * Refresh icon if required, true if label was changed.
171: * <p>
172: * Icon will be placed on GENERATED_ICON
173: * </p>
174: *
175: * @param layer
176: * @param notify
177: * @return true if label was changed
178: */
179: private boolean refreshIcon(Layer layer) {
180: try {
181: ImageDescriptor icon = generateIcon(layer);
182: if (icon != null) {
183: layer.getProperties().put(GENERATED_ICON, icon);
184: return true;
185: }
186: } catch (Throwable problem) {
187: ProjectUIPlugin
188: .getDefault()
189: .getLog()
190: .log(
191: new Status(
192: IStatus.WARNING,
193: ProjectUIPlugin.ID,
194: IStatus.INFO,
195: "Could not generate layer glyph " + layer, problem)); //$NON-NLS-1$
196: // layer.setStatus(Layer.WARNING);
197: }
198: return false;
199: }
200:
201: /**
202: * Refresh icon if required, true if icon was changed.
203: *
204: * @param layer
205: * @param notify
206: * @return ture if icon was changed
207: */
208: private boolean refreshLabel(Layer layer) {
209: String label = label(layer);
210:
211: if (label == null || label.length() == 0) {
212: try {
213: String gen = generateLabel(layer);
214: // System.out.println( "generated "+ gen );
215: if (gen != null) {
216: layer.getProperties().putString(GENERATED_NAME,
217: gen);
218: return true;
219: }
220: } catch (Throwable problem) {
221: ProjectUIPlugin
222: .getDefault()
223: .getLog()
224: .log(
225: new Status(
226: IStatus.WARNING,
227: ProjectUIPlugin.ID,
228: IStatus.INFO,
229: "Could not generate name for " + layer, problem)); //$NON-NLS-1$
230: // layer.setStatus(Layer.WARNING);
231: }
232: }
233: return false;
234: }
235: };
236:
237: private static LayerGeneratedGlyphDecorator instance = null;
238:
239: public static LayerGeneratedGlyphDecorator getInstance() {
240: return instance;
241: }
242:
243: public LayerGeneratedGlyphDecorator() {
244: picasso.setSystem(true);
245: picasso.setPriority(Job.DECORATE);
246: picasso.schedule();
247: instance = this ;
248: }
249:
250: Set<ILabelProviderListener> listeners = new CopyOnWriteArraySet<ILabelProviderListener>();
251:
252: Adapter hack = new AdapterImpl() {
253: public void notifyChanged(Notification msg) {
254: if (msg.getNotifier() instanceof Layer) {
255: final Layer layer = (Layer) msg.getNotifier();
256: if (queue == null) {
257: // we can stop listening now nobody cares
258: layer.eAdapters().remove(this );
259: return;
260: }
261: switch (msg.getFeatureID(Layer.class)) {
262: case ProjectPackage.LAYER__GLYPH:
263: case ProjectPackage.LAYER__STYLE_BLACKBOARD:
264: case ProjectPackage.LAYER__NAME:
265: case ProjectPackage.LAYER__GEO_RESOURCES:
266: layer.getProperties().put(GENERATED_ICON, null);
267: layer.getProperties().put(GENERATED_NAME, null);
268: refresh(layer);
269: break;
270: }
271: }
272: }
273: };
274:
275: void refresh(Layer layer) {
276: if (listeners.isEmpty())
277: return;
278: LabelProviderChangedEvent event = new LabelProviderChangedEvent(
279: this , layer);
280: for (ILabelProviderListener listener : listeners) {
281: listener.labelProviderChanged(event);
282: }
283: }
284:
285: /** Cache of images by resource id */
286: static ImageCache cache = new ImageCache();
287:
288: /**
289: * A non null answer when layer has a good label.
290: * <p>
291: * Where a good/real means:
292: * <ul>
293: * <li>label.getLabel() != null
294: * <li>label.getProperties().getSTring( GENERATED_NAME ) != null
295: * </p>
296: * <p>
297: * This method does not block and used used by the decorateText and our thread to test/acquire
298: * the right text. If null is returned the thread will be started in the hopes of producing
299: * something.
300: * <p>
301: *
302: * @returns Label for layer, or <code>null</code> if unavailable
303: */
304: static String label(Layer layer) {
305: String label = layer.getName();
306: if (label != null && label.length() > 0)
307: return label; // layer has a user supplied name
308:
309: label = layer.getProperties().getString(GENERATED_NAME);
310: if (label != null)
311: return label; // we have already generated one
312:
313: return null;
314: }
315:
316: /**
317: * Genearte label.
318: * <p>
319: * This is used to generate a value for layer.getProperties().getString( GENERATED_NAME ).
320: * <p>
321: * The generated label from Resource.getInfo().getTitle(). This method will block and should not
322: * be called from the event thread.
323: * </p>
324: *
325: * @return gernated layer, or <code>null</code> if none can be determined
326: */
327: public static String generateLabel(Layer layer) {
328: IGeoResource resource = layer.getGeoResources().get(0);
329: if (resource == null)
330: return null;
331:
332: IGeoResourceInfo info = null;
333: try {
334: info = resource.getInfo(null);
335: } catch (IOException e) {
336: return null; // aka use origional label provided by item provider
337: }
338: if (info == null) {
339: return null; // aka use origional label provided by item provider
340: }
341:
342: String title = info.getTitle();
343: if (title != null)
344: return title;
345:
346: String name = info.getName();
347: if (name != null)
348: return name;
349:
350: // Side note: Original label, made by item provider uses,
351: // resource.getIdentifier() which is non blocking
352: //
353: return null; // give up
354: }
355:
356: /**
357: * A non null answer when layer has a good gylph.
358: * <p>
359: * Where a good/real means:
360: * <ul>
361: * <li>label.getGylph() != null
362: * <li>label.getProperties().get( GENERATED_GYLPH ) != null
363: * </p>
364: * <p>
365: * This method does not block and used used by the decorateImage and our thread to test/acquire
366: * the right image. If <code>null</code> is returned the thread will be started in the hopes
367: * of producing something.
368: * <p>
369: *
370: * @returns Image for layer, or <code>null</code> if unavailable Image icon( Layer layer ) {
371: * ImageDescriptor glyph = layer.getGlyph(); if (glyph != null) return
372: * cache.getImage(glyph); Image genglyph = (Image)
373: * layer.getProperties().get(GENERATED_ICON); if (genglyph != null &&
374: * !genglyph.isDisposed() ) return genglyph; // we have already generated one return
375: * null; }
376: */
377:
378: /**
379: * Genearte label and place in label.getProperties().getSTring( GENERATED_NAME ).
380: * <p>
381: * Label is genrated from Resource.
382: * </p>
383: *
384: * @return gernated layer
385: */
386: public static ImageDescriptor generateIcon(Layer layer) {
387: StyleBlackboard style = layer.getStyleBlackboard();
388:
389: if (style != null && !style.getContent().isEmpty()) {
390: ImageDescriptor icon = generateStyledIcon(layer);
391: if (icon != null)
392: return icon;
393: }
394: ImageDescriptor icon = generateDefaultIcon(layer);
395: if (icon != null)
396: return icon;
397: return null;
398: }
399:
400: /**
401: * Generate icon based on style information.
402: * <p>
403: * Will return null if an icom based on the current style could not be generated. You may
404: * consult generateDefaultIcon( layer ) for a second opionion based on just the layer
405: * information.
406: *
407: * @param layer
408: * @return ImageDecriptor for layer, or null in style could not be indicated
409: */
410: public static ImageDescriptor generateStyledIcon(Layer layer) {
411: StyleBlackboard blackboard = layer.getStyleBlackboard();
412: if (blackboard == null)
413: return null;
414:
415: Style sld = (Style) blackboard.lookup(Style.class); // or
416: // blackboard.get(
417: // "net.refractions.udig.style.sld"
418: // );
419: if (sld != null) {
420: Rule rule = getRule(sld);
421: return generateStyledIcon(layer, rule);
422: }
423: if (layer.hasResource(WebMapServer.class)) {
424: return null; // do not support styling for wms yet
425: }
426: return null;
427: }
428:
429: private static Rule getRule(Style sld) {
430: Rule rule = null;
431: int size = 0;
432:
433: for (FeatureTypeStyle style : sld.getFeatureTypeStyles()) {
434: for (Rule potentialRule : style.getRules()) {
435: if (potentialRule != null) {
436: Symbolizer[] symbs = potentialRule.getSymbolizers();
437: for (int m = 0; m < symbs.length; m++) {
438: if (symbs[m] instanceof PointSymbolizer) {
439: int newSize = SLDs
440: .pointSize((PointSymbolizer) symbs[m]);
441: if (newSize > 16 && size != 0) {
442: // return with previous rule
443: return rule;
444: }
445: size = newSize;
446: rule = potentialRule;
447: } else {
448: return potentialRule;
449: }
450: }
451: }
452: }
453: }
454: return rule;
455: }
456:
457: public static ImageDescriptor generateStyledIcon(Layer layer,
458: Rule rule) {
459: if (layer.hasResource(FeatureSource.class) && rule != null) {
460: FeatureType type = layer.getSchema();
461: GeometryAttributeType geom = type.getDefaultGeometry();
462: if (geom != null) {
463: Class geom_type = geom.getType();
464: if (geom_type == Point.class
465: || geom_type == MultiPoint.class) {
466: return Glyph.point(rule);
467: } else if (geom_type == LineString.class
468: || geom_type == MultiLineString.class) {
469: return Glyph.line(rule);
470: } else if (geom_type == Polygon.class
471: || geom_type == MultiPolygon.class) {
472: return Glyph.polygon(rule);
473: } else if (geom_type == Geometry.class
474: || geom_type == GeometryCollection.class) {
475: return Glyph.geometry(rule);
476: }
477: }
478: }
479: IGeoResource resource = layer
480: .findGeoResource(FeatureSource.class);
481: if (resource == null)
482: return null;
483: IGeoResourceInfo info;
484: try {
485: info = resource.getInfo(null);
486: } catch (IOException e) {
487: info = null;
488: }
489: if (info != null) {
490: ImageDescriptor infoIcon = info.getIcon();
491: if (infoIcon != null)
492: return infoIcon;
493: }
494: if (resource.canResolve(GridCoverageReader.class)) {
495: ImageDescriptor icon = Glyph.grid(null, null, null, null);
496: if (icon != null)
497: return icon;
498: }
499: if (resource.canResolve(FeatureSource.class)) {
500: ImageDescriptor icon = Glyph.geometry(rule);
501: if (icon != null)
502: return icon;
503: }
504: return null;
505: }
506:
507: /**
508: * Generate icon based on simple layer type information without style.
509: * <p>
510: * The following information is checked:
511: * <ul>
512: * <li>All WMS resources known to the layer - they often have default icon
513: * <li>FeatureSoruce known to the layer - icon can be based on FeatureType
514: * <li>IGeoResourceInfo type information
515: * </ul>
516: * </p>
517: *
518: * @param layer
519: * @return Icon based on layer, null if unavailable
520: */
521: static ImageDescriptor generateDefaultIcon(Layer layer) {
522: // check for a WMS layer first as it has a pretty icon
523: if (layer.hasResource(WebMapServer.class)
524: && layer.hasResource(ImageDescriptor.class)) {
525: try {
526: ImageDescriptor legendGraphic = layer.getResource(
527: ImageDescriptor.class, null);
528: if (legendGraphic != null)
529: return legendGraphic;
530: } catch (IOException notAvailable) {
531: // should not really have happened
532: }
533: }
534: // lets try for featuretype based glyph
535: //
536: if (layer.hasResource(FeatureSource.class)) {
537:
538: FeatureType type = layer.getSchema();
539: GeometryAttributeType geom = type.getDefaultGeometry();
540: if (geom != null) {
541: Class geom_type = geom.getType();
542: if (geom_type == Point.class
543: || geom_type == MultiPoint.class) {
544: return Glyph.point(null, null);
545: } else if (geom_type == LineString.class
546: || geom_type == MultiLineString.class) {
547: return Glyph.line(null, SLDs.NOTFOUND);
548: } else if (geom_type == Polygon.class
549: || geom_type == MultiPolygon.class) {
550: return Glyph.polygon(null, null, SLDs.NOTFOUND);
551: } else if (geom_type == Geometry.class
552: || geom_type == GeometryCollection.class) {
553: return Glyph.geometry(null, null);
554: } else {
555: return Glyph.geometry(null, null);
556: }
557: }
558: }
559:
560: //
561: // Resource based glyph?
562: //
563: IGeoResourceInfo info = null;
564: try {
565: info = layer.getGeoResources().get(0).getInfo(null);
566: } catch (IOException e) {
567: //
568: }
569: if (info != null) {
570: ImageDescriptor infoIcon = info.getIcon();
571: if (infoIcon != null)
572: return infoIcon;
573: }
574:
575: if (layer.hasResource(GridCoverageReader.class)) {
576: ImageDescriptor icon = Glyph.grid(null, null, null, null);
577: if (icon != null)
578: return icon;
579: }
580: if (layer.hasResource(FeatureSource.class)) {
581: ImageDescriptor icon = Glyph.geometry(null, null);
582: if (icon != null)
583: return icon;
584: }
585: return null; // default probided by lable provider will have to do
586: }
587:
588: /**
589: * @see org.eclipse.jface.viewers.ILabelDecorator#decorateText(java.lang.String,
590: * java.lang.Object)
591: */
592: public String decorateText(String text, Object element) {
593: if (!(element instanceof Layer))
594: return null;
595: Layer layer = (Layer) element;
596: try {
597: String label = label(layer); // test for label name or generated
598: // name
599:
600: if (label != null && label.length() != 0)
601: return label;
602:
603: synchronized (queue) {
604: if (!queue.contains(layer)) {
605: queue.add(layer); // thread will wake up and generate us a
606: // layer
607: picasso.schedule();
608: }
609: }
610: } catch (Throwable problem) {
611: ProjectUIPlugin
612: .getDefault()
613: .getLog()
614: .log(
615: new Status(
616: IStatus.WARNING,
617: ProjectUIPlugin.ID,
618: IStatus.INFO,
619: "Generated name unavailable " + layer, problem)); //$NON-NLS-1$
620: }
621: return null; // use existing default from item provider
622: }
623:
624: /**
625: * We are not allowed to block, test if generation is needed and start up the queue.
626: * <p>
627: * State Table of Image \ Image Descriptor:
628: *
629: * <pre><code>
630: * | null | icon
631: * ---------+--------------+---------------------+
632: * disposed | queue | image = |
633: * or null | layer | icon.createImage() |
634: * ---------+--------------+---------------------+
635: * image | both | image |
636: * +--------------+---------------------+
637: * </code></pre>
638: *
639: * This attempts to reduce the amount of flicker experienced as the layer figures out its glyph
640: * in the face of many events.
641: * </p>
642: * <p>
643: * Everyone gives us events - who gives us icons?
644: * <ul>
645: * <li>If the user has given the layer an icon we don't need to generate anything.
646: * <li>piccaso will wait on the queue and generate icons, and refresh the decorator.
647: * <li>We will get the refresh and generate an Image from the Icon, we can use this image when
648: * we are nexted refreshed.
649: * <li>A random eclipse code will dispose our Images, and refrsh us (We can still generate our
650: * images from the saved icon).
651: * <li>The listener *hack* will watch for changes to layer,if any look interesting the icon
652: * will be cleared and we will be refreshed. We still have our image so their will be no
653: * downtime while waiting for piccaso to make us a new Icon.
654: * </ul>
655: * So what happens for a layer that we cannot generate a icon for? We will place it in the queue
656: * *every* time. Who knows maybe style or something will change and we can do better then the
657: * default.
658: * </p>
659: *
660: * @see org.eclipse.jface.viewers.ILabelDecorator#decorateImage(org.eclipse.swt.graphics.Image,
661: * java.lang.Object)
662: */
663: public Image decorateImage(Image origionalImage, Object element) {
664: if (!(element instanceof Layer))
665: return null;
666:
667: Layer layer = (Layer) element;
668: if (layer.getGlyph() != null)
669: return null; // don't replace user's glyph
670:
671: ImageDescriptor icon = (ImageDescriptor) layer.getProperties()
672: .get(GENERATED_ICON);
673: if (icon == null) { // we need to generate our icon - check every time
674: // it may now be possible
675: synchronized (queue) {
676: queue.add(layer); // thread will wake up and generate us a
677: // layer
678: picasso.schedule();
679: }
680: }
681: return null; // next time through the origionalImage will be based on
682: // our icon
683: /*
684: * Image image = (Image) layer.getProperties().get(GENERATED_IMAGE); if( image != null ){
685: * if( !image.isDisposed()){ //return image; // we have an image already to go } image =
686: * null; // forget this image it is dead layer.getProperties().put( GENERATED_IMAGE, null ); }
687: * if( icon != null ){ Image newImage = icon.createImage(); // returns null on error if(
688: * newImage != null ){ layer.getProperties().put( GENERATED_IMAGE, newImage ); return
689: * newImage; } icon = null; // icon did not work - better clear it and try again.
690: * layer.getProperties().put( GENERATED_ICON, null ); } return null; // use default from
691: * item provider (often based on GeoResource type)
692: */
693: }
694:
695: /**
696: * @see org.eclipse.jface.viewers.IBaseLabelProvider#addListener(org.eclipse.jface.viewers.ILabelProviderListener)
697: */
698: public void addListener(ILabelProviderListener listener) {
699: listeners.add(listener);
700: }
701:
702: /**
703: * @see org.eclipse.jface.viewers.IBaseLabelProvider#dispose()
704: */
705: public void dispose() {
706: picasso.cancel();
707: Thread.yield();
708: disposed = true;
709: queue.clear();
710:
711: // should clean up after hack
712: if (listeners != null) {
713: listeners.clear();
714: listeners = null;
715: }
716: if (cache != null) {
717: cache.dispose();
718: cache = null;
719: }
720: }
721:
722: /**
723: * @see org.eclipse.jface.viewers.IBaseLabelProvider#isLabelProperty(java.lang.Object,
724: * java.lang.String)
725: */
726: public boolean isLabelProperty(Object element, String property) {
727: return true;
728: /*
729: * return "glyph".equals( property ) || "styleBlackboard".equals( property ) ||
730: * "name".equals( property ) || "geoResources".equals( property );
731: */
732: }
733:
734: /**
735: * @see org.eclipse.jface.viewers.IBaseLabelProvider#removeListener(org.eclipse.jface.viewers.ILabelProviderListener)
736: */
737: public void removeListener(ILabelProviderListener listener) {
738: listeners.remove(listener);
739: }
740:
741: }
|