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