001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.mappaint; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.Dimension; 007import java.awt.image.BufferedImage; 008import java.io.File; 009import java.io.FileNotFoundException; 010import java.io.IOException; 011import java.nio.file.Files; 012import java.nio.file.Paths; 013import java.util.ArrayList; 014import java.util.Arrays; 015import java.util.List; 016import java.util.Locale; 017import java.util.Optional; 018import java.util.function.DoubleSupplier; 019import java.util.logging.Level; 020 021import javax.imageio.ImageIO; 022 023import org.openstreetmap.gui.jmapviewer.OsmMercator; 024import org.openstreetmap.josm.cli.CLIModule; 025import org.openstreetmap.josm.data.Bounds; 026import org.openstreetmap.josm.data.ProjectionBounds; 027import org.openstreetmap.josm.data.coor.EastNorth; 028import org.openstreetmap.josm.data.coor.LatLon; 029import org.openstreetmap.josm.data.coor.conversion.LatLonParser; 030import org.openstreetmap.josm.data.osm.DataSet; 031import org.openstreetmap.josm.data.preferences.JosmBaseDirectories; 032import org.openstreetmap.josm.data.preferences.JosmUrls; 033import org.openstreetmap.josm.data.projection.Projection; 034import org.openstreetmap.josm.data.projection.ProjectionRegistry; 035import org.openstreetmap.josm.data.projection.Projections; 036import org.openstreetmap.josm.gui.mappaint.RenderingHelper.StyleData; 037import org.openstreetmap.josm.io.IllegalDataException; 038import org.openstreetmap.josm.io.OsmReader; 039import org.openstreetmap.josm.spi.preferences.Config; 040import org.openstreetmap.josm.spi.preferences.MemoryPreferences; 041import org.openstreetmap.josm.tools.JosmDecimalFormatSymbolsProvider; 042import org.openstreetmap.josm.tools.Logging; 043import org.openstreetmap.josm.tools.OptionParser; 044import org.openstreetmap.josm.tools.OptionParser.OptionCount; 045import org.openstreetmap.josm.tools.OptionParser.OptionParseException; 046import org.openstreetmap.josm.tools.RightAndLefthandTraffic; 047 048/** 049 * Command line interface for rendering osm data to an image file. 050 * 051 * @since 12906 052 */ 053public class RenderingCLI implements CLIModule { 054 055 /** 056 * The singleton instance of this class. 057 */ 058 public static final RenderingCLI INSTANCE = new RenderingCLI(); 059 060 private static final double PIXEL_PER_METER = 96 / 2.54 * 100; // standard value of 96 dpi display resolution 061 private static final int DEFAULT_MAX_IMAGE_SIZE = 20000; 062 063 private boolean argDebug; 064 private boolean argTrace; 065 private String argInput; 066 private String argOutput; 067 private List<StyleData> argStyles; 068 private Integer argZoom; 069 private Double argScale; 070 private Bounds argBounds; 071 private LatLon argAnchor; 072 private Double argWidthM; 073 private Double argHeightM; 074 private Integer argWidthPx; 075 private Integer argHeightPx; 076 private String argProjection; 077 private Integer argMaxImageSize; 078 079 private StyleData argCurrentStyle; 080 081 private enum Option { 082 HELP(false, 'h'), 083 DEBUG(false, '*'), 084 TRACE(false, '*'), 085 INPUT(true, 'i'), 086 STYLE(true, 's'), 087 SETTING(true, '*'), 088 OUTPUT(true, 'o'), 089 ZOOM(true, 'z'), 090 SCALE(true, '*'), 091 BOUNDS(true, 'b'), 092 ANCHOR(true, '*'), 093 WIDTH_M(true, '*'), 094 HEIGHT_M(true, '*'), 095 WIDTH_PX(true, '*'), 096 HEIGHT_PX(true, '*'), 097 PROJECTION(true, '*'), 098 MAX_IMAGE_SIZE(true, '*'); 099 100 private final String name; 101 private final boolean requiresArg; 102 private final char shortOption; 103 104 Option(boolean requiresArgument, char shortOption) { 105 this.name = name().toLowerCase(Locale.US).replace('_', '-'); 106 this.requiresArg = requiresArgument; 107 this.shortOption = shortOption; 108 } 109 110 /** 111 * Replies the option name 112 * @return The option name, in lowercase 113 */ 114 public String getName() { 115 return name; 116 } 117 118 /** 119 * Determines if this option requires an argument. 120 * @return {@code true} if this option requires an argument, {@code false} otherwise 121 */ 122 public boolean requiresArgument() { 123 return requiresArg; 124 } 125 126 /** 127 * Replies the short option (single letter) associated with this option. 128 * @return the short option or '*' if there is no short option 129 */ 130 public char getShortOption() { 131 return shortOption; 132 } 133 } 134 135 /** 136 * Data class to hold return values for {@link #determineRenderingArea(DataSet)}. 137 * 138 * Package private access for unit tests. 139 */ 140 static class RenderingArea { 141 public Bounds bounds; 142 public double scale; // in east-north units per pixel (unlike the --scale option, which is in meter per meter) 143 } 144 145 RenderingCLI() { 146 // hide constructor (package private access for unit tests) 147 } 148 149 @Override 150 public String getActionKeyword() { 151 return "render"; 152 } 153 154 @Override 155 public void processArguments(String[] argArray) { 156 try { 157 parseArguments(argArray); 158 initialize(); 159 DataSet ds = loadDataset(); 160 RenderingArea area = determineRenderingArea(ds); 161 RenderingHelper rh = new RenderingHelper(ds, area.bounds, area.scale, argStyles); 162 checkPreconditions(rh); 163 BufferedImage image = rh.render(); 164 writeImageToFile(image); 165 } catch (FileNotFoundException e) { 166 if (Logging.isDebugEnabled()) { 167 e.printStackTrace(); 168 } 169 System.err.println(tr("Error - file not found: ''{0}''", e.getMessage())); 170 System.exit(1); 171 } catch (IllegalArgumentException | IllegalDataException | IOException e) { 172 if (Logging.isDebugEnabled()) { 173 e.printStackTrace(); 174 } 175 if (e.getMessage() != null) { 176 System.err.println(tr("Error: {0}", e.getMessage())); 177 } 178 System.exit(1); 179 } 180 System.exit(0); 181 } 182 183 /** 184 * Parse command line arguments and do some low-level error checking. 185 * @param argArray the arguments array 186 */ 187 void parseArguments(String[] argArray) { 188 Logging.setLogLevel(Level.INFO); 189 190 OptionParser parser = new OptionParser("JOSM rendering"); 191 for (Option o : Option.values()) { 192 if (o.requiresArgument()) { 193 parser.addArgumentParameter(o.getName(), 194 o == Option.SETTING ? OptionCount.MULTIPLE : OptionCount.OPTIONAL, 195 arg -> handleOption(o, arg)); 196 } else { 197 parser.addFlagParameter(o.getName(), () -> handleOption(o)); 198 } 199 if (o.getShortOption() != '*') { 200 parser.addShortAlias(o.getName(), Character.toString(o.getShortOption())); 201 } 202 } 203 204 argCurrentStyle = new StyleData(); 205 argStyles = new ArrayList<>(); 206 207 parser.parseOptionsOrExit(Arrays.asList(argArray)); 208 209 if (argCurrentStyle.styleUrl != null) { 210 argStyles.add(argCurrentStyle); 211 } 212 } 213 214 private void handleOption(Option o) { 215 switch (o) { 216 case HELP: 217 showHelp(); 218 System.exit(0); 219 break; 220 case DEBUG: 221 argDebug = true; 222 break; 223 case TRACE: 224 argTrace = true; 225 break; 226 default: 227 throw new AssertionError("Unexpected option index: " + o); 228 } 229 } 230 231 private void handleOption(Option o, String arg) { 232 switch (o) { 233 case INPUT: 234 argInput = arg; 235 break; 236 case STYLE: 237 if (argCurrentStyle.styleUrl != null) { 238 argStyles.add(argCurrentStyle); 239 argCurrentStyle = new StyleData(); 240 } 241 argCurrentStyle.styleUrl = arg; 242 break; 243 case OUTPUT: 244 argOutput = arg; 245 break; 246 case ZOOM: 247 try { 248 argZoom = Integer.valueOf(arg); 249 } catch (NumberFormatException nfe) { 250 throw new OptionParseException( 251 tr("Expected integer number for option {0}, but got ''{1}''", "--zoom", arg), nfe); 252 } 253 if (argZoom < 0) { 254 throw new OptionParseException( 255 tr("Expected integer number >= 0 for option {0}, but got ''{1}''", "--zoom", arg)); 256 } 257 break; 258 case BOUNDS: 259 if (!"auto".equals(arg)) { 260 try { 261 argBounds = new Bounds(arg, ",", Bounds.ParseMethod.LEFT_BOTTOM_RIGHT_TOP, false); 262 } catch (IllegalArgumentException iae) { // NOPMD 263 throw new OptionParseException( 264 tr("Unable to parse {0} parameter: {1}", "--bounds", iae.getMessage()), iae); 265 } 266 } 267 break; 268 269 case SETTING: 270 String keyval = arg; 271 String[] comp = keyval.split(":", 2); 272 if (comp.length != 2) { 273 throw new OptionParseException( 274 tr("Expected key and value, separated by '':'' character for option {0}, but got ''{1}''", 275 "--setting", arg)); 276 } 277 argCurrentStyle.settings.put(comp[0].trim(), comp[1].trim()); 278 break; 279 case SCALE: 280 try { 281 argScale = JosmDecimalFormatSymbolsProvider.parseDouble(arg); 282 } catch (NumberFormatException nfe) { 283 throw new OptionParseException( 284 tr("Expected floating point number for option {0}, but got ''{1}''", "--scale", arg), nfe); 285 } 286 break; 287 case ANCHOR: 288 String[] parts = arg.split(","); 289 if (parts.length != 2) 290 throw new OptionParseException( 291 tr("Expected two coordinates, separated by comma, for option {0}, but got ''{1}''", "--anchor", 292 arg)); 293 try { 294 double lon = LatLonParser.parseCoordinate(parts[0]); 295 double lat = LatLonParser.parseCoordinate(parts[1]); 296 argAnchor = new LatLon(lat, lon); 297 } catch (IllegalArgumentException iae) { // NOPMD 298 throw new OptionParseException(tr("In option {0}: {1}", "--anchor", iae.getMessage()), iae); 299 } 300 break; 301 case WIDTH_M: 302 try { 303 argWidthM = JosmDecimalFormatSymbolsProvider.parseDouble(arg); 304 } catch (NumberFormatException nfe) { 305 throw new OptionParseException( 306 tr("Expected floating point number for option {0}, but got ''{1}''", "--width-m", arg), nfe); 307 } 308 if (argWidthM <= 0) 309 throw new OptionParseException( 310 tr("Expected floating point number > 0 for option {0}, but got ''{1}''", "--width-m", arg)); 311 break; 312 case HEIGHT_M: 313 try { 314 argHeightM = JosmDecimalFormatSymbolsProvider.parseDouble(arg); 315 } catch (NumberFormatException nfe) { 316 throw new OptionParseException( 317 tr("Expected floating point number for option {0}, but got ''{1}''", "--height-m", arg), nfe); 318 } 319 if (argHeightM <= 0) 320 throw new OptionParseException( 321 tr("Expected floating point number > 0 for option {0}, but got ''{1}''", "--width-m", arg)); 322 break; 323 case WIDTH_PX: 324 try { 325 argWidthPx = Integer.valueOf(arg); 326 } catch (NumberFormatException nfe) { 327 throw new OptionParseException( 328 tr("Expected integer number for option {0}, but got ''{1}''", "--width-px", arg), nfe); 329 } 330 if (argWidthPx <= 0) 331 throw new OptionParseException( 332 tr("Expected integer number > 0 for option {0}, but got ''{1}''", "--width-px", arg)); 333 break; 334 case HEIGHT_PX: 335 try { 336 argHeightPx = Integer.valueOf(arg); 337 } catch (NumberFormatException nfe) { 338 throw new OptionParseException( 339 tr("Expected integer number for option {0}, but got ''{1}''", "--height-px", arg), nfe); 340 } 341 if (argHeightPx <= 0) { 342 throw new OptionParseException( 343 tr("Expected integer number > 0 for option {0}, but got ''{1}''", "--height-px", arg)); 344 } 345 break; 346 case PROJECTION: 347 argProjection = arg; 348 break; 349 case MAX_IMAGE_SIZE: 350 try { 351 argMaxImageSize = Integer.valueOf(arg); 352 } catch (NumberFormatException nfe) { 353 throw new OptionParseException( 354 tr("Expected integer number for option {0}, but got ''{1}''", "--max-image-size", arg), nfe); 355 } 356 if (argMaxImageSize < 0) { 357 throw new OptionParseException( 358 tr("Expected integer number >= 0 for option {0}, but got ''{1}''", "--max-image-size", arg)); 359 } 360 break; 361 default: 362 throw new AssertionError("Unexpected option index: " + o); 363 } 364 } 365 366 /** 367 * Displays help on the console 368 */ 369 public static void showHelp() { 370 System.out.println(getHelp()); 371 } 372 373 private static String getHelp() { 374 return tr("JOSM rendering command line interface")+"\n\n"+ 375 tr("Usage")+":\n"+ 376 "\tjava -jar josm.jar render <options>\n\n"+ 377 tr("Description")+":\n"+ 378 tr("Renders data and saves the result to an image file.")+"\n\n"+ 379 tr("Options")+":\n"+ 380 "\t--help|-h "+tr("Show this help")+"\n"+ 381 "\t--input|-i <file> "+tr("Input data file name (.osm)")+"\n"+ 382 "\t--output|-o <file> "+tr("Output image file name (.png); defaults to ''{0}''", "out.png")+"\n"+ 383 "\t--style|-s <file> "+tr("Style file to use for rendering (.mapcss or .zip)")+"\n"+ 384 "\t "+tr("This option can be repeated to load multiple styles.")+"\n"+ 385 "\t--setting <key>:<value> "+tr("Style setting (in JOSM accessible in the style list dialog right click menu)")+"\n"+ 386 "\t "+tr("Applies to the last style loaded with the {0} option.", "--style")+"\n"+ 387 "\t--zoom|-z <lvl> "+tr("Select zoom level to render. (integer value, 0=entire earth, 18=street level)")+"\n"+ 388 "\t--scale <scale> "+tr("Select the map scale")+"\n"+ 389 "\t "+tr("A value of 10000 denotes a scale of 1:10000 (1 cm on the map equals 100 m on the ground; " 390 + "display resolution: 96 dpi)")+"\n"+ 391 "\t "+tr("Options {0} and {1} are mutually exclusive.", "--zoom", "--scale")+"\n"+ 392 "\t--bounds|-b auto|<min_lon>,<min_lat>,<max_lon>,<max_lat>\n"+ 393 "\t "+tr("Area to render, default value is ''{0}''", "auto")+"\n"+ 394 "\t "+tr("With keyword ''{0}'', the downloaded area in the .osm input file will be used (if recorded).", 395 "auto")+"\n"+ 396 "\t--anchor <lon>,<lat> "+tr("Specify bottom left corner of the rendering area")+"\n"+ 397 "\t "+tr("Used in combination with width and height options to determine the area to render.")+"\n"+ 398 "\t--width-m <number> "+tr("Width of the rendered area, in meter")+"\n"+ 399 "\t--height-m <number> "+tr("Height of the rendered area, in meter")+"\n"+ 400 "\t--width-px <number> "+tr("Width of the target image, in pixel")+"\n"+ 401 "\t--height-px <number> "+tr("Height of the target image, in pixel")+"\n"+ 402 "\t--projection <code> "+tr("Projection to use, default value ''{0}'' (web-Mercator)", "epsg:3857")+"\n"+ 403 "\t--max-image-size <number> "+tr("Maximum image width/height in pixel (''{0}'' means no limit), default value: {1}", 404 0, Integer.toString(DEFAULT_MAX_IMAGE_SIZE))+"\n"+ 405 "\n"+ 406 tr("To specify the rendered area and scale, the options can be combined in various ways")+":\n"+ 407 " * --bounds (--zoom|--scale|--width-px|--height-px)\n"+ 408 " * --anchor (--width-m|--width-px) (--height-m|--height-px) (--zoom|--scale)\n"+ 409 " * --anchor --width-m --height-m (--width-px|--height-px)\n"+ 410 " * --anchor --width-px --height-px (--width-m|--height-m)\n"+ 411 tr("If neither ''{0}'' nor ''{1}'' is given, the default value {2} takes effect " 412 + "and the bounds of the download area in the .osm input file are used.", 413 "bounds", "anchor", "--bounds=auto")+"\n\n"+ 414 tr("Examples")+":\n"+ 415 " java -jar josm.jar render -i data.osm -s style.mapcss -z 16\n"+ 416 " josm render -i data.osm -s style.mapcss --scale 5000\n"+ 417 " josm render -i data.osm -s style.mapcss -z 16 -o image.png\n"+ 418 " josm render -i data.osm -s elemstyles.mapcss --setting hide_icons:false -z 16\n"+ 419 " josm render -i data.osm -s style.mapcss -s another_style.mapcss -z 16 -o image.png\n"+ 420 " josm render -i data.osm -s style.mapcss --bounds 21.151,51.401,21.152,51.402 -z 16\n"+ 421 " josm render -i data.osm -s style.mapcss --anchor 21.151,51.401 --width-m 500 --height-m 300 -z 16\n"+ 422 " josm render -i data.osm -s style.mapcss --anchor 21.151,51.401 --width-m 500 --height-m 300 --width-px 1800\n"+ 423 " josm render -i data.osm -s style.mapcss --scale 5000 --projection epsg:4326\n"; 424 } 425 426 /** 427 * Initialization. 428 * 429 * Requires arguments to be parsed already ({@link #parseArguments(java.lang.String[])}). 430 */ 431 void initialize() { 432 Logging.setLogLevel(getLogLevel()); 433 434 Config.setBaseDirectoriesProvider(JosmBaseDirectories.getInstance()); // for right-left-hand traffic cache file 435 Config.setPreferencesInstance(new MemoryPreferences()); 436 Config.setUrlsProvider(JosmUrls.getInstance()); 437 Config.getPref().putBoolean("mappaint.auto_reload_local_styles", false); // unnecessary to listen for external changes 438 String projCode = Optional.ofNullable(argProjection).orElse("epsg:3857"); 439 ProjectionRegistry.setProjection(Projections.getProjectionByCode(projCode.toUpperCase(Locale.US))); 440 441 RightAndLefthandTraffic.initialize(); 442 } 443 444 private Level getLogLevel() { 445 if (argTrace) { 446 return Logging.LEVEL_TRACE; 447 } else if (argDebug) { 448 return Logging.LEVEL_DEBUG; 449 } else { 450 return Logging.LEVEL_INFO; 451 } 452 } 453 454 /** 455 * Find the area to render and the scale, given certain command line options and the dataset. 456 * @param ds the dataset 457 * @return area to render and the scale 458 */ 459 RenderingArea determineRenderingArea(DataSet ds) { 460 461 Projection proj = ProjectionRegistry.getProjection(); 462 Double scale = null; // scale in east-north units per pixel 463 if (argZoom != null) { 464 scale = OsmMercator.EARTH_RADIUS * Math.PI * 2 / Math.pow(2, argZoom) / OsmMercator.DEFAUL_TILE_SIZE 465 / proj.getMetersPerUnit(); 466 } 467 Bounds bounds = argBounds; 468 ProjectionBounds pb = null; 469 470 if (bounds == null) { 471 if (argAnchor != null) { 472 EastNorth projAnchor = proj.latlon2eastNorth(argAnchor); 473 474 double enPerMeter = Double.NaN; 475 DoubleSupplier getEnPerMeter = () -> { 476 double shiftMeter = 10; 477 EastNorth projAnchorShifted = projAnchor.add(shiftMeter / proj.getMetersPerUnit(), 478 shiftMeter / proj.getMetersPerUnit()); 479 LatLon anchorShifted = proj.eastNorth2latlon(projAnchorShifted); 480 return projAnchor.distance(projAnchorShifted) / argAnchor.greatCircleDistance(anchorShifted); 481 }; 482 483 if (scale == null) { 484 if (argScale != null) { 485 enPerMeter = getEnPerMeter.getAsDouble(); 486 scale = argScale * enPerMeter / PIXEL_PER_METER; 487 } else if (argWidthM != null && argWidthPx != null) { 488 enPerMeter = getEnPerMeter.getAsDouble(); 489 scale = argWidthM / argWidthPx * enPerMeter; 490 } else if (argHeightM != null && argHeightPx != null) { 491 enPerMeter = getEnPerMeter.getAsDouble(); 492 scale = argHeightM / argHeightPx * enPerMeter; 493 } else { 494 throw new IllegalArgumentException( 495 tr("Argument {0} given, but scale cannot be determined from remaining arguments", 496 "--anchor")); 497 } 498 } 499 500 double widthEn; 501 if (argWidthM != null) { 502 if (Double.isNaN(enPerMeter)) { 503 enPerMeter = getEnPerMeter.getAsDouble(); 504 } 505 widthEn = argWidthM * enPerMeter; 506 } else if (argWidthPx != null) { 507 widthEn = argWidthPx * scale; 508 } else { 509 throw new IllegalArgumentException( 510 tr("Argument {0} given, expected {1} or {2}", "--anchor", "--width-m", "--width-px")); 511 } 512 513 double heightEn; 514 if (argHeightM != null) { 515 if (Double.isNaN(enPerMeter)) { 516 enPerMeter = getEnPerMeter.getAsDouble(); 517 } 518 heightEn = argHeightM * enPerMeter; 519 } else if (argHeightPx != null) { 520 heightEn = argHeightPx * scale; 521 } else { 522 throw new IllegalArgumentException( 523 tr("Argument {0} given, expected {1} or {2}", "--anchor", "--height-m", "--height-px")); 524 } 525 pb = new ProjectionBounds(projAnchor); 526 pb.extend(new EastNorth(projAnchor.east() + widthEn, projAnchor.north() + heightEn)); 527 bounds = new Bounds(proj.eastNorth2latlon(pb.getMin()), false); 528 bounds.extend(proj.eastNorth2latlon(pb.getMax())); 529 } else { 530 if (ds.getDataSourceBounds().isEmpty()) { 531 throw new IllegalArgumentException( 532 tr("{0} mode, but no bounds found in osm data input file", "--bounds=auto")); 533 } 534 bounds = ds.getDataSourceBounds().get(0); 535 } 536 } 537 538 if (pb == null) { 539 pb = new ProjectionBounds(); 540 pb.extend(proj.latlon2eastNorth(bounds.getMin())); 541 pb.extend(proj.latlon2eastNorth(bounds.getMax())); 542 } 543 544 if (scale == null) { 545 if (argScale != null) { 546 double enPerMeter = pb.getMin().distance(pb.getMax()) 547 / bounds.getMin().greatCircleDistance(bounds.getMax()); 548 scale = argScale * enPerMeter / PIXEL_PER_METER; 549 } else if (argWidthPx != null) { 550 scale = (pb.maxEast - pb.minEast) / argWidthPx; 551 } else if (argHeightPx != null) { 552 scale = (pb.maxNorth - pb.minNorth) / argHeightPx; 553 } else { 554 throw new IllegalArgumentException( 555 tr("Unable to determine scale, one of the options {0}, {1}, {2} or {3} expected", "--zoom", 556 "--scale", "--width-px", "--height-px")); 557 } 558 } 559 560 RenderingArea ra = new RenderingArea(); 561 ra.bounds = bounds; 562 ra.scale = scale; 563 return ra; 564 } 565 566 private DataSet loadDataset() throws IOException, IllegalDataException { 567 if (argInput == null) { 568 throw new IllegalArgumentException(tr("Missing argument - input data file ({0})", "--input|-i")); 569 } 570 try { 571 return OsmReader.parseDataSet(Files.newInputStream(Paths.get(argInput)), null); 572 } catch (IllegalDataException e) { 573 throw new IllegalDataException(tr("In .osm data file ''{0}'' - ", argInput) + e.getMessage(), e); 574 } 575 } 576 577 private void checkPreconditions(RenderingHelper rh) { 578 if (argStyles.isEmpty()) 579 throw new IllegalArgumentException(tr("Missing argument - at least one style expected ({0})", "--style")); 580 581 Dimension imgSize = rh.getImageSize(); 582 Logging.debug("image size (px): {0}x{1}", imgSize.width, imgSize.height); 583 int maxSize = Optional.ofNullable(argMaxImageSize).orElse(DEFAULT_MAX_IMAGE_SIZE); 584 if (maxSize != 0 && (imgSize.width > maxSize || imgSize.height > maxSize)) { 585 throw new IllegalArgumentException( 586 tr("Image dimensions ({0}x{1}) exceeds maximum image size {2} (use option {3} to change limit)", 587 imgSize.width, imgSize.height, maxSize, "--max-image-size")); 588 } 589 } 590 591 private void writeImageToFile(BufferedImage image) throws IOException { 592 String output = Optional.ofNullable(argOutput).orElse("out.png"); 593 ImageIO.write(image, "png", new File(output)); 594 } 595}