001/* 002 * GeoAPI - Java interfaces for OGC/ISO standards 003 * http://www.geoapi.org 004 * 005 * Copyright (C) 2011-2019 Open Geospatial Consortium, Inc. 006 * All Rights Reserved. http://www.opengeospatial.org/ogc/legal 007 * 008 * Permission to use, copy, and modify this software and its documentation, with 009 * or without modification, for any purpose and without fee or royalty is hereby 010 * granted, provided that you include the following on ALL copies of the software 011 * and documentation or portions thereof, including modifications, that you make: 012 * 013 * 1. The full text of this NOTICE in a location viewable to users of the 014 * redistributed or derivative work. 015 * 2. Notice of any changes or modifications to the OGC files, including the 016 * date changes were made. 017 * 018 * THIS SOFTWARE AND DOCUMENTATION IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE 019 * NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 020 * TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT 021 * THE USE OF THE SOFTWARE OR DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY 022 * PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. 023 * 024 * COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR 025 * CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENTATION. 026 * 027 * The name and trademarks of copyright holders may NOT be used in advertising or 028 * publicity pertaining to the software without specific, written prior permission. 029 * Title to copyright in this software and any associated documentation will at all 030 * times remain with copyright holders. 031 */ 032package org.opengis.test.report; 033 034import java.net.URI; 035import java.io.File; 036import java.io.FileOutputStream; 037import java.io.FileNotFoundException; 038import java.io.InputStream; 039import java.io.InputStreamReader; 040import java.io.LineNumberReader; 041import java.io.OutputStream; 042import java.io.OutputStreamWriter; 043import java.io.BufferedWriter; 044import java.io.IOException; 045import java.util.Date; 046import java.util.Locale; 047import java.util.Properties; 048import java.util.NoSuchElementException; 049import java.text.SimpleDateFormat; 050 051import org.opengis.util.InternationalString; 052import org.opengis.metadata.Identifier; 053import org.opengis.metadata.citation.Citation; 054import org.opengis.metadata.citation.Contact; 055import org.opengis.metadata.citation.OnlineResource; 056import org.opengis.metadata.citation.Party; 057import org.opengis.metadata.citation.Responsibility; 058 059 060/** 061 * Base class for tools generating reports as HTML pages. The reports are based on HTML templates 062 * with a few keywords to be replaced by user-provided values. The values associated to keywords 063 * can be specified in two ways: 064 * 065 * <ul> 066 * <li>Specified at {@linkplain #Report(Properties) construction time}.</li> 067 * <li>Stored directly in the {@linkplain #properties} map by subclasses.</li> 068 * </ul> 069 * 070 * The set of keywords, and whether a user-provided value for a given keyword is mandatory or optional, 071 * is subclass-specific. However most subclasses expect at least the following keywords: 072 * 073 * <table class="ogc"> 074 * <caption>Report properties</caption> 075 * <tr><th>Key</th> <th align="center">Remarks</th> <th>Meaning</th></tr> 076 * <tr><td>{@code TITLE}</td> <td align="center"> </td> <td>Title of the web page to produce.</td></tr> 077 * <tr><td>{@code DATE}</td> <td align="center">automatic</td> <td>Date of report creation.</td></tr> 078 * <tr><td>{@code DESCRIPTION}</td> <td align="center">optional</td> <td>Description to write after the introductory paragraph.</td></tr> 079 * <tr><td>{@code OBJECTS.KIND}</td> <td align="center"> </td> <td>Kind of objects listed in the page (e.g. <cite>"Operation Methods"</cite>).</td></tr> 080 * <tr><td>{@code PRODUCT.NAME}</td> <td align="center"> </td> <td>Name of the product for which the report is generated.</td></tr> 081 * <tr><td>{@code PRODUCT.VERSION}</td><td align="center"> </td> <td>Version of the product for which the report is generated.</td></tr> 082 * <tr><td>{@code PRODUCT.URL}</td> <td align="center"> </td> <td>URL where more information is available about the product.</td></tr> 083 * <tr><td>{@code JAVADOC.GEOAPI}</td> <td align="center">predefined</td><td>Base URL of GeoAPI javadoc.</td></tr> 084 * <tr><td>{@code FILENAME}</td> <td align="center">predefined</td><td>Name of the file to create if the {@link #write(File)} argument is a directory.</td></tr> 085 * </table> 086 * 087 * <p><b>How to use this class:</b></p> 088 * <ul> 089 * <li>Create a {@link Properties} map with the values documented in the subclass to be 090 * instantiated. Default values exist for many keys, but those defaults may depend 091 * on the environment (information found in {@code META-INF/MANIFEST.MF}, <i>etc</i>). 092 * It is safer to specify values explicitly when they are known.</li> 093 * <li>Create a new instance of the {@code Report} subclass with the above properties map 094 * given to the constructor.</li> 095 * <li>All {@code Report} subclasses define at least one {@code add(…)} method for declaring 096 * the objects to include in the HTML page. At least one object or factory needs to be 097 * declared.</li> 098 * <li>Invoke {@link #write(File)}.</li> 099 * </ul> 100 * 101 * @author Martin Desruisseaux (Geomatys) 102 * @version 3.1 103 * 104 * @since 3.1 105 */ 106public abstract class Report { 107 /** 108 * The encoding of every reports. 109 */ 110 private static final String ENCODING = "UTF-8"; 111 112 /** 113 * The prefix before key names in HTML templates. 114 */ 115 private static final String KEY_PREFIX = "${"; 116 117 /** 118 * The suffix after key names in HTML templates. 119 */ 120 private static final char KEY_SUFFIX = '}'; 121 122 /** 123 * Number of spaces to add when we increase the indentation. 124 */ 125 static final int INDENT = 2; 126 127 /** 128 * The timestamp at the time this class has been initialized. 129 * Will be used as the default value for {@code PRODUCT.VERSION}. 130 */ 131 private static final String NOW; 132 static { 133 final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd", Locale.CANADA); 134 NOW = format.format(new Date()); 135 } 136 137 /** 138 * The values to substitute to keywords in the HTML templates. This map is initialized to a 139 * copy of the map given by the user at {@linkplain #Report(Properties) construction time}, 140 * or to an empty map if the user gave a {@code null} map. Subclasses can freely add, edit 141 * or remove entries in this map. 142 * 143 * <p>The list of expected entries and their {@linkplain Properties#defaults default values} 144 * (if any) are subclass-specific. See the subclass javadoc for a list of expected values.</p> 145 */ 146 protected final Properties properties; 147 148 /** 149 * The properties to use as a fallback if a key was not found in the {@link #properties} map. 150 * Subclasses defined in the {@code org.opengis.test.report} package shall put their default 151 * values here. Note that the default values are highly implementation-specific and may change 152 * in any future version. We don't document them (for now) for this reason. 153 */ 154 final Properties defaultProperties; 155 156 /** 157 * The listener object to inform about the progress, or {@code null} if none. 158 */ 159 ProgressListener listener; 160 161 /** 162 * Creates a new report generator using the given property values. The list of expected 163 * entries is subclass specific and shall be documented in their javadoc. 164 * 165 * @param properties the property values, or {@code null} for the default values. 166 */ 167 protected Report(final Properties properties) { 168 defaultProperties = new Properties(); 169 defaultProperties.setProperty("TITLE", getClass().getSimpleName()); 170 defaultProperties.setProperty("DATE", NOW); 171 defaultProperties.setProperty("DESCRIPTION", ""); 172 defaultProperties.setProperty("PRODUCT.VERSION", NOW); 173 defaultProperties.setProperty("FACTORY.VERSION", NOW); 174 defaultProperties.setProperty("JAVADOC.GEOAPI", "http://www.geoapi.org/snapshot/javadoc"); 175 this.properties = new Properties(defaultProperties); 176 if (properties != null) { 177 this.properties.putAll(properties); 178 } 179 } 180 181 /** 182 * Infers default values for the "{@code PRODUCT.NAME}", "{@code PRODUCT.VERSION}" and "{@code PRODUCT.URL}" 183 * {@linkplain #properties} from the given vendor. The vendor argument is typically the value obtained by a 184 * call to the {@linkplain org.opengis.util.Factory#getVendor()} method. 185 * 186 * @param prefix the property key prefix (usually {@code "PRODUCT"}, but may also be {@code "FACTORY"}). 187 * @param vendor the vendor, or {@code null}. 188 * 189 * @see org.opengis.util.Factory#getVendor() 190 */ 191 final void setVendor(final String prefix, final Citation vendor) { 192 if (vendor != null) { 193 String title = toString(vendor.getTitle()); 194 /* 195 * Search for the version number, with opportunist assignment 196 * to the 'title' variable if it was not set by the above line. 197 */ 198 String version = null; 199 for (final Identifier identifier : IdentifiedObjects.nullSafe(vendor.getIdentifiers())) { 200 if (identifier == null) continue; // Paranoiac safety. 201 if (title == null) { 202 title = identifier.getCode(); 203 } 204 if (version == null) { 205 version = identifier.getVersion(); 206 } 207 if (title != null && version != null) { 208 break; // No need to continue. 209 } 210 } 211 /* 212 * Search for a URL, with opportunist assignment to the 'title' variable 213 * if it was not set by the above lines. 214 */ 215 String linkage = null; 216search: for (final Responsibility responsibility : vendor.getCitedResponsibleParties()) { 217 if (responsibility == null) continue; // Paranoiac safety. 218 for (final Party party : responsibility.getParties()) { 219 if (party == null) continue; // Paranoiac safety. 220 if (title == null) { 221 title = toString(party.getName()); 222 } 223 for (final Contact contact : party.getContactInfo()) { 224 if (contact == null) continue; // Paranoiac safety. 225 for (final OnlineResource resource : contact.getOnlineResources()) { 226 if (resource == null) continue; // Paranoiac safety. 227 final URI uri = resource.getLinkage(); 228 if (uri != null) { 229 linkage = uri.toString(); 230 if (title != null) { // This is the usual case. 231 break search; 232 } 233 } 234 } 235 } 236 } 237 } 238 /* 239 * If we found at least one property, set all of them together 240 * (including null values) for consistency. 241 */ 242 if (title != null) defaultProperties.setProperty(prefix + ".NAME", title); 243 if (version != null) defaultProperties.setProperty(prefix + ".VERSION", version); 244 if (linkage != null) defaultProperties.setProperty(prefix + ".URL", linkage); 245 } 246 } 247 248 /** 249 * Returns the value associated to the given key in the {@linkplain #properties} map. 250 * If the value for the given key contains other keys, then this method will resolve 251 * those values recursively. 252 * 253 * @param key the property key for which to get the value. 254 * @return the value for the given key. 255 * @throws NoSuchElementException if no value has been found for the given key. 256 */ 257 final String getProperty(final String key) throws NoSuchElementException { 258 final StringBuilder buffer = new StringBuilder(); 259 try { 260 writeProperty(buffer, key); 261 } catch (IOException e) { 262 // Should never happen, since we are appending to a StringBuilder. 263 throw new AssertionError(e); 264 } 265 return buffer.toString(); 266 } 267 268 /** 269 * Returns the locale to use for producing messages in the reports. The locale may be 270 * used for fetching the character sequences from {@link InternationalString} objects, 271 * for converting to lower-cases or for formatting numbers. 272 * 273 * <p>The locale is fixed to {@linkplain Locale#ENGLISH English} for now, but may become 274 * modifiable in a future version.</p> 275 * 276 * @return the locale to use for formatting messages. 277 * 278 * @see InternationalString#toString(Locale) 279 * @see String#toLowerCase(Locale) 280 * @see java.text.NumberFormat#getNumberInstance(Locale) 281 */ 282 public Locale getLocale() { 283 return Locale.ENGLISH; 284 } 285 286 /** 287 * Returns a string value for the given text. If the given text is an instance of {@link InternationalString}, 288 * then this method fetches the string for the {@linkplain #locale current locale}. 289 */ 290 final String toString(final CharSequence text) { 291 if (text == null) { 292 return null; 293 } 294 if (text instanceof InternationalString) { 295 return ((InternationalString) text).toString(getLocale()); 296 } 297 return text.toString(); 298 } 299 300 /** 301 * Ensures that the given {@link File} object denotes a file (not a directory). 302 * If the given argument is a directory, then the {@code "FILENAME"} property value will be added. 303 */ 304 final File toFile(File destination) { 305 if (destination.isDirectory()) { 306 destination = new File(destination, properties.getProperty("FILENAME", getClass().getSimpleName() + ".html")); 307 } 308 return destination; 309 } 310 311 /** 312 * Generates the HTML report in the given file or directory. 313 * If the given argument is a directory, then the path will be completed with the {@code "FILENAME"} 314 * {@linkplain #properties} value if any, or an implementation specific default filename otherwise. 315 * 316 * <p>Note that the target directory must exist; this method does not create any new directory.</p> 317 * 318 * @param destination the destination file or directory. 319 * If this file already exists, then its content will be overwritten without warning. 320 * @return the file to the HTML page generated by this report. This is usually the given 321 * {@code destination} argument, unless the destination was a directory. 322 * @throws IOException if an error occurred while writing the report. 323 */ 324 public abstract File write(final File destination) throws IOException; 325 326 /** 327 * Copies the given resource file to the given directory. 328 * This method does nothing if the destination file already exits. 329 * 330 * @param source the name of the resource to copy. 331 * @param directory the destination directory. 332 * @throws IOException if an error occurred during the copy. 333 */ 334 private static void copy(final String source, final File directory) throws IOException { 335 final File file = new File(directory, source); 336 if (file.isFile() && file.length() != 0) { 337 return; 338 } 339 final InputStream in = Report.class.getResourceAsStream(source); 340 if (in == null) { 341 throw new FileNotFoundException("Resource not found: " + source); 342 } 343 try (OutputStream out = new FileOutputStream(file)) { 344 int n; 345 final byte[] buffer = new byte[1024]; 346 while ((n = in.read(buffer)) >= 0) { 347 out.write(buffer, 0, n); 348 } 349 } finally { 350 in.close(); 351 } 352 } 353 354 /** 355 * Copies the given resource to the given file, replacing the {@code ${FOO}} occurrences in the process. 356 * For each occurrence of a {@code ${FOO}} keyword, this method invokes the 357 * {@link #writeContent(BufferedWriter, String)} method. 358 * 359 * @param source the resource name, without path. 360 * @param destination the destination file. Will be overwritten if already presents. 361 * @throws IOException if an error occurred while reading the resource or writing the file. 362 */ 363 final void filter(final String source, final File destination) throws IOException { 364 copy("geoapi-reports.css", destination.getParentFile()); 365 final InputStream in = Report.class.getResourceAsStream(source); 366 if (in == null) { 367 throw new FileNotFoundException("Resource not found: " + source); 368 } 369 final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(destination), ENCODING)); 370 final LineNumberReader reader = new LineNumberReader(new InputStreamReader(in, ENCODING)); 371 reader.setLineNumber(1); 372 try { 373 String line; 374 while ((line = reader.readLine()) != null) { 375 if (!writeLine(writer, line)) { 376 throw new IOException(KEY_PREFIX + " without " + KEY_SUFFIX + 377 " at line " + reader.getLineNumber() + ":\n" + line); 378 } 379 writer.newLine(); 380 } 381 } finally { 382 writer.close(); 383 reader.close(); 384 } 385 } 386 387 /** 388 * Writes the given line, replacing replacing the {@code ${FOO}} occurrences in the process. 389 * For each occurrence of a {@code ${FOO}} keyword, this method invokes the 390 * {@link #writeContent(BufferedWriter, String)} method. 391 * 392 * <p>This method does not invokes {@link BufferedWriter#newLine()} after the line. 393 * This is caller responsibility to invoke {@code newLine()} is desired.</p> 394 * 395 * @param out the output writer. 396 * @param line the line to write. 397 * @return {@code true} on success, or {@code false} on malformed {@code ${FOO}} key. 398 * @throws IOException if an error occurred while writing to the file. 399 */ 400 private boolean writeLine(final Appendable out, final String line) throws IOException { 401 int endOfPreviousPass = 0; 402 for (int i=line.indexOf(KEY_PREFIX); i >= 0; i=line.indexOf(KEY_PREFIX, i)) { 403 out.append(line, endOfPreviousPass, i); 404 final int stop = line.indexOf(KEY_SUFFIX, i += 2); 405 if (stop < 0) { 406 return false; 407 } 408 final String key = line.substring(i, stop).trim(); 409 if (out instanceof BufferedWriter) { 410 writeContent((BufferedWriter) out, key); 411 } else { 412 writeProperty(out, key); 413 } 414 endOfPreviousPass = stop + 1; 415 i = endOfPreviousPass; 416 } 417 out.append(line, endOfPreviousPass, line.length()); 418 return true; 419 } 420 421 /** 422 * Writes the property value for the given key. 423 * 424 * @param out the output writer. 425 * @param key the key to replace by a content. 426 * @throws NoSuchElementException if no value has been found for the given key. 427 * @throws IOException if an error occurred while writing the content. 428 */ 429 private void writeProperty(final Appendable out, final String key) throws NoSuchElementException, IOException { 430 final String value = properties.getProperty(key); 431 if (value == null) { 432 throw new NoSuchElementException("Undefined property: " + key); 433 } 434 if (!writeLine(out, value)) { 435 throw new IOException(KEY_PREFIX + " without " + KEY_SUFFIX + " for property \"" + key + "\":\n" + value); 436 } 437 } 438 439 /** 440 * Invoked every time a {@code ${FOO}} occurrence is found. The default implementation gets 441 * the value from the {@linkplain #properties} map. Subclasses can override this method in 442 * order to compute the actual content here. 443 * 444 * <p>If the value for the given key contains other keys, then this method 445 * invokes itself recursively for resolving those values.</p> 446 * 447 * @param out the output writer. 448 * @param key the key to replace by a content. 449 * @throws NoSuchElementException if no value has been found for the given key. 450 * @throws IOException if an error occurred while writing the content. 451 */ 452 void writeContent(final BufferedWriter out, final String key) throws NoSuchElementException, IOException { 453 writeProperty(out, key); 454 } 455 456 /** 457 * Writes the indentation spaces on the left margin. 458 */ 459 static void writeIndentation(final Appendable out, int indentation) throws IOException { 460 while (--indentation >= 0) { 461 out.append(' '); 462 } 463 } 464 465 /** 466 * Writes the {@code class="…"} attribute values inside a HTML element. 467 * 468 * @param classes an arbitrary number of classes in the SLD. Length can be 0, 1, 2 or more. 469 * Any null element will be silently ignored. 470 */ 471 static void writeClassAttribute(final Appendable out, final String... classes) throws IOException { 472 boolean hasClasses = false; 473 for (final String classe : classes) { 474 if (classe != null) { 475 out.append(' '); 476 if (!hasClasses) { 477 out.append("class=\""); 478 hasClasses = true; 479 } 480 out.append(classe); 481 } 482 } 483 if (hasClasses) { 484 out.append('"'); 485 } 486 } 487 488 /** 489 * Escape {@code <} and {@code >} characters for HTML. This method is null-safe. 490 * Empty strings are replaced by {@code null} value. 491 */ 492 static String escape(String text) { 493 if (text != null) { 494 text = text.replace("<", "<").replace(">", ">").trim(); 495 if (text.isEmpty()) { 496 text = null; 497 } 498 } 499 return text; 500 } 501 502 /** 503 * Invoked when the report is making some progress. This is typically invoked from a 504 * {@code add(…)} method, since they are usually slower than {@link #write(File)}. 505 * Subclasses can override this method if they want to be notified about progress. 506 * 507 * @param position a number ranging from 0 to {@code count}. 508 * This is typically the number or rows created so far for the HTML table to write. 509 * @param count the maximal expected value of {@code position}. Note that this value may change between 510 * different invocations if the report gets a better estimation about the number of rows to be created. 511 */ 512 protected void progress(final int position, final int count) { 513 final ProgressListener listener = this.listener; 514 if (listener != null) { 515 listener.progress(position, count); 516 } 517 } 518}