001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.commons.imaging.formats.png; 018 019import java.awt.image.BufferedImage; 020import java.io.ByteArrayOutputStream; 021import java.io.IOException; 022import java.io.OutputStream; 023import java.nio.charset.StandardCharsets; 024import java.util.List; 025import java.util.zip.Deflater; 026import java.util.zip.DeflaterOutputStream; 027 028import org.apache.commons.imaging.ImagingException; 029import org.apache.commons.imaging.PixelDensity; 030import org.apache.commons.imaging.common.Allocator; 031import org.apache.commons.imaging.internal.Debug; 032import org.apache.commons.imaging.palette.Palette; 033import org.apache.commons.imaging.palette.PaletteFactory; 034 035public class PngWriter { 036 037 /* 038 * 1. IHDR: image header, which is the first chunk in a PNG data stream. 2. PLTE: palette table associated with indexed PNG images. 3. IDAT: image data 039 * chunks. 4. IEND: image trailer, which is the last chunk in a PNG data stream. 040 * 041 * The remaining 14 chunk types are termed ancillary chunk types, which encoders may generate and decoders may interpret. 042 * 043 * 1. Transparency information: tRNS (see 11.3.2: Transparency information). 2. Color space information: cHRM, gAMA, iCCP, sBIT, sRGB (see 11.3.3: Color 044 * space information). 3. Textual information: iTXt, tEXt, zTXt (see 11.3.4: Textual information). 4. Miscellaneous information: bKGD, hIST, pHYs, sPLT (see 045 * 11.3.5: Miscellaneous information). 5. Time information: tIME (see 11.3.6: Time stamp information). 046 */ 047 048 private static final class ImageHeader { 049 public final int width; 050 public final int height; 051 public final byte bitDepth; 052 public final PngColorType pngColorType; 053 public final byte compressionMethod; 054 public final byte filterMethod; 055 public final InterlaceMethod interlaceMethod; 056 057 ImageHeader(final int width, final int height, final byte bitDepth, final PngColorType pngColorType, final byte compressionMethod, 058 final byte filterMethod, final InterlaceMethod interlaceMethod) { 059 this.width = width; 060 this.height = height; 061 this.bitDepth = bitDepth; 062 this.pngColorType = pngColorType; 063 this.compressionMethod = compressionMethod; 064 this.filterMethod = filterMethod; 065 this.interlaceMethod = interlaceMethod; 066 } 067 068 } 069 070 private byte[] deflate(final byte[] bytes) throws IOException { 071 try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { 072 try (DeflaterOutputStream dos = new DeflaterOutputStream(baos)) { 073 dos.write(bytes); 074 // dos.flush() doesn't work - we must close it before baos.toByteArray() 075 } 076 return baos.toByteArray(); 077 } 078 } 079 080 private byte getBitDepth(final PngColorType pngColorType, final PngImagingParameters params) { 081 final byte depth = params.getBitDepth(); 082 083 return pngColorType.isBitDepthAllowed(depth) ? depth : PngImagingParameters.DEFAULT_BIT_DEPTH; 084 } 085 086 private boolean isValidISO_8859_1(final String s) { 087 final String roundtrip = new String(s.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.ISO_8859_1); 088 return s.equals(roundtrip); 089 } 090 091 private void writeChunk(final OutputStream os, final ChunkType chunkType, final byte[] data) throws IOException { 092 final int dataLength = data == null ? 0 : data.length; 093 writeInt(os, dataLength); 094 os.write(chunkType.array); 095 if (data != null) { 096 os.write(data); 097 } 098 099 final PngCrc pngCrc = new PngCrc(); 100 101 final long crc1 = pngCrc.startPartialCrc(chunkType.array, chunkType.array.length); 102 final long crc2 = data == null ? crc1 : pngCrc.continuePartialCrc(crc1, data, data.length); 103 final int crc = (int) pngCrc.finishPartialCrc(crc2); 104 105 writeInt(os, crc); 106 } 107 108 private void writeChunkIDAT(final OutputStream os, final byte[] bytes) throws IOException { 109 writeChunk(os, ChunkType.IDAT, bytes); 110 } 111 112 private void writeChunkIEND(final OutputStream os) throws IOException { 113 writeChunk(os, ChunkType.IEND, null); 114 } 115 116 private void writeChunkIHDR(final OutputStream os, final ImageHeader value) throws IOException { 117 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 118 writeInt(baos, value.width); 119 writeInt(baos, value.height); 120 baos.write(0xff & value.bitDepth); 121 baos.write(0xff & value.pngColorType.getValue()); 122 baos.write(0xff & value.compressionMethod); 123 baos.write(0xff & value.filterMethod); 124 baos.write(0xff & value.interlaceMethod.ordinal()); 125 126 writeChunk(os, ChunkType.IHDR, baos.toByteArray()); 127 } 128 129 private void writeChunkiTXt(final OutputStream os, final AbstractPngText.Itxt text) throws IOException, ImagingException { 130 if (!isValidISO_8859_1(text.keyword)) { 131 throw new ImagingException("PNG tEXt chunk keyword is not ISO-8859-1: " + text.keyword); 132 } 133 if (!isValidISO_8859_1(text.languageTag)) { 134 throw new ImagingException("PNG tEXt chunk language tag is not ISO-8859-1: " + text.languageTag); 135 } 136 137 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 138 139 // keyword 140 baos.write(text.keyword.getBytes(StandardCharsets.ISO_8859_1)); 141 baos.write(0); 142 143 baos.write(1); // compressed flag, true 144 baos.write(PngConstants.COMPRESSION_DEFLATE_INFLATE); // compression method 145 146 // language tag 147 baos.write(text.languageTag.getBytes(StandardCharsets.ISO_8859_1)); 148 baos.write(0); 149 150 // translated keyword 151 baos.write(text.translatedKeyword.getBytes(StandardCharsets.UTF_8)); 152 baos.write(0); 153 154 baos.write(deflate(text.text.getBytes(StandardCharsets.UTF_8))); 155 156 writeChunk(os, ChunkType.iTXt, baos.toByteArray()); 157 } 158 159 private void writeChunkPHYS(final OutputStream os, final int xPPU, final int yPPU, final byte units) throws IOException { 160 final byte[] bytes = new byte[9]; 161 bytes[0] = (byte) (0xff & xPPU >> 24); 162 bytes[1] = (byte) (0xff & xPPU >> 16); 163 bytes[2] = (byte) (0xff & xPPU >> 8); 164 bytes[3] = (byte) (0xff & xPPU >> 0); 165 bytes[4] = (byte) (0xff & yPPU >> 24); 166 bytes[5] = (byte) (0xff & yPPU >> 16); 167 bytes[6] = (byte) (0xff & yPPU >> 8); 168 bytes[7] = (byte) (0xff & yPPU >> 0); 169 bytes[8] = units; 170 writeChunk(os, ChunkType.pHYs, bytes); 171 } 172 173 private void writeChunkPLTE(final OutputStream os, final Palette palette) throws IOException { 174 final int length = palette.length(); 175 final byte[] bytes = Allocator.byteArray(length * 3); 176 177 // Debug.debug("length", length); 178 for (int i = 0; i < length; i++) { 179 final int rgb = palette.getEntry(i); 180 final int index = i * 3; 181 // Debug.debug("index", index); 182 bytes[index + 0] = (byte) (0xff & rgb >> 16); 183 bytes[index + 1] = (byte) (0xff & rgb >> 8); 184 bytes[index + 2] = (byte) (0xff & rgb >> 0); 185 } 186 187 writeChunk(os, ChunkType.PLTE, bytes); 188 } 189 190 private void writeChunkSCAL(final OutputStream os, final double xUPP, final double yUPP, final byte units) throws IOException { 191 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 192 193 // unit specifier 194 baos.write(units); 195 196 // units per pixel, x-axis 197 baos.write(String.valueOf(xUPP).getBytes(StandardCharsets.ISO_8859_1)); 198 baos.write(0); 199 200 baos.write(String.valueOf(yUPP).getBytes(StandardCharsets.ISO_8859_1)); 201 202 writeChunk(os, ChunkType.sCAL, baos.toByteArray()); 203 } 204 205 private void writeChunktEXt(final OutputStream os, final AbstractPngText.Text text) throws IOException, ImagingException { 206 if (!isValidISO_8859_1(text.keyword)) { 207 throw new ImagingException("PNG tEXt chunk keyword is not ISO-8859-1: " + text.keyword); 208 } 209 if (!isValidISO_8859_1(text.text)) { 210 throw new ImagingException("PNG tEXt chunk text is not ISO-8859-1: " + text.text); 211 } 212 213 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 214 215 // keyword 216 baos.write(text.keyword.getBytes(StandardCharsets.ISO_8859_1)); 217 baos.write(0); 218 219 // text 220 baos.write(text.text.getBytes(StandardCharsets.ISO_8859_1)); 221 222 writeChunk(os, ChunkType.tEXt, baos.toByteArray()); 223 } 224 225 private void writeChunkTRNS(final OutputStream os, final Palette palette) throws IOException { 226 final byte[] bytes = Allocator.byteArray(palette.length()); 227 228 for (int i = 0; i < bytes.length; i++) { 229 bytes[i] = (byte) (0xff & palette.getEntry(i) >> 24); 230 } 231 232 writeChunk(os, ChunkType.tRNS, bytes); 233 } 234 235 private void writeChunkXmpiTXt(final OutputStream os, final String xmpXml) throws IOException { 236 237 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 238 239 // keyword 240 baos.write(PngConstants.XMP_KEYWORD.getBytes(StandardCharsets.ISO_8859_1)); 241 baos.write(0); 242 243 baos.write(1); // compressed flag, true 244 baos.write(PngConstants.COMPRESSION_DEFLATE_INFLATE); // compression method 245 246 baos.write(0); // language tag (ignore). TODO 247 248 // translated keyword 249 baos.write(PngConstants.XMP_KEYWORD.getBytes(StandardCharsets.UTF_8)); 250 baos.write(0); 251 252 baos.write(deflate(xmpXml.getBytes(StandardCharsets.UTF_8))); 253 254 writeChunk(os, ChunkType.iTXt, baos.toByteArray()); 255 } 256 257 private void writeChunkzTXt(final OutputStream os, final AbstractPngText.Ztxt text) throws IOException, ImagingException { 258 if (!isValidISO_8859_1(text.keyword)) { 259 throw new ImagingException("PNG zTXt chunk keyword is not ISO-8859-1: " + text.keyword); 260 } 261 if (!isValidISO_8859_1(text.text)) { 262 throw new ImagingException("PNG zTXt chunk text is not ISO-8859-1: " + text.text); 263 } 264 265 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 266 267 // keyword 268 baos.write(text.keyword.getBytes(StandardCharsets.ISO_8859_1)); 269 baos.write(0); 270 271 // compression method 272 baos.write(PngConstants.COMPRESSION_DEFLATE_INFLATE); 273 274 // text 275 baos.write(deflate(text.text.getBytes(StandardCharsets.ISO_8859_1))); 276 277 writeChunk(os, ChunkType.zTXt, baos.toByteArray()); 278 } 279 280 /* 281 * between two chunk types indicates alternatives. Table 5.3 - Chunk ordering rules Critical chunks (shall appear in this order, except PLTE is optional) 282 * Chunk name Multiple allowed Ordering constraints IHDR No Shall be first PLTE No Before first IDAT IDAT Yes Multiple IDAT chunks shall be consecutive IEND 283 * No Shall be last Ancillary chunks (need not appear in this order) Chunk name Multiple allowed Ordering constraints cHRM No Before PLTE and IDAT gAMA No 284 * Before PLTE and IDAT iCCP No Before PLTE and IDAT. If the iCCP chunk is present, the sRGB chunk should not be present. sBIT No Before PLTE and IDAT sRGB 285 * No Before PLTE and IDAT. If the sRGB chunk is present, the iCCP chunk should not be present. bKGD No After PLTE; before IDAT hIST No After PLTE; before 286 * IDAT tRNS No After PLTE; before IDAT pHYs No Before IDAT sCAL No Before IDAT sPLT Yes Before IDAT tIME No None iTXt Yes None tEXt Yes None zTXt Yes None 287 */ 288 289 /** 290 * Writes an image to an output stream. 291 * 292 * @param src The image to write. 293 * @param os The output stream to write to. 294 * @param params The parameters to use (can be {@code NULL} to use the default {@link PngImagingParameters}). 295 * @param paletteFactory The palette factory to use (can be {@code NULL} to use the default {@link PaletteFactory}). 296 * @throws ImagingException When errors are detected. 297 * @throws IOException When IO problems occur. 298 */ 299 public void writeImage(final BufferedImage src, final OutputStream os, PngImagingParameters params, PaletteFactory paletteFactory) 300 throws ImagingException, IOException { 301 if (params == null) { 302 params = new PngImagingParameters(); 303 } 304 if (paletteFactory == null) { 305 paletteFactory = new PaletteFactory(); 306 } 307 final int compressionLevel = Deflater.DEFAULT_COMPRESSION; 308 309 final int width = src.getWidth(); 310 final int height = src.getHeight(); 311 312 final boolean hasAlpha = paletteFactory.hasTransparency(src); 313 Debug.debug("hasAlpha: " + hasAlpha); 314 // int transparency = paletteFactory.getTransparency(src); 315 316 boolean isGrayscale = paletteFactory.isGrayscale(src); 317 Debug.debug("isGrayscale: " + isGrayscale); 318 319 PngColorType pngColorType; 320 { 321 final boolean forceIndexedColor = params.isForceIndexedColor(); 322 final boolean forceTrueColor = params.isForceTrueColor(); 323 324 if (forceIndexedColor && forceTrueColor) { 325 throw new ImagingException("Params: Cannot force both indexed and true color modes"); 326 } 327 if (forceIndexedColor) { 328 pngColorType = PngColorType.INDEXED_COLOR; 329 } else if (forceTrueColor) { 330 pngColorType = hasAlpha ? PngColorType.TRUE_COLOR_WITH_ALPHA : PngColorType.TRUE_COLOR; 331 isGrayscale = false; 332 } else { 333 pngColorType = PngColorType.getColorType(hasAlpha, isGrayscale); 334 } 335 Debug.debug("colorType: " + pngColorType); 336 } 337 338 final byte bitDepth = getBitDepth(pngColorType, params); 339 Debug.debug("bitDepth: " + bitDepth); 340 341 int sampleDepth; 342 if (pngColorType == PngColorType.INDEXED_COLOR) { 343 sampleDepth = 8; 344 } else { 345 sampleDepth = bitDepth; 346 } 347 Debug.debug("sampleDepth: " + sampleDepth); 348 349 { 350 PngConstants.PNG_SIGNATURE.writeTo(os); 351 } 352 { 353 // IHDR must be first 354 355 final byte compressionMethod = PngConstants.COMPRESSION_TYPE_INFLATE_DEFLATE; 356 final byte filterMethod = PngConstants.FILTER_METHOD_ADAPTIVE; 357 final InterlaceMethod interlaceMethod = InterlaceMethod.NONE; 358 359 final ImageHeader imageHeader = new ImageHeader(width, height, bitDepth, pngColorType, compressionMethod, filterMethod, interlaceMethod); 360 361 writeChunkIHDR(os, imageHeader); 362 } 363 364 // { 365 // sRGB No Before PLTE and IDAT. If the sRGB chunk is present, the 366 // iCCP chunk should not be present. 367 368 // charles 369 // } 370 371 Palette palette = null; 372 if (pngColorType == PngColorType.INDEXED_COLOR) { 373 // PLTE No Before first IDAT 374 375 final int maxColors = 256; 376 377 if (hasAlpha) { 378 palette = paletteFactory.makeQuantizedRgbaPalette(src, hasAlpha, maxColors); 379 writeChunkPLTE(os, palette); 380 writeChunkTRNS(os, palette); 381 } else { 382 palette = paletteFactory.makeQuantizedRgbPalette(src, maxColors); 383 writeChunkPLTE(os, palette); 384 } 385 } 386 387 final Object pixelDensityObj = params.getPixelDensity(); 388 if (pixelDensityObj != null) { 389 final PixelDensity pixelDensity = (PixelDensity) pixelDensityObj; 390 if (pixelDensity.isUnitless()) { 391 writeChunkPHYS(os, (int) Math.round(pixelDensity.getRawHorizontalDensity()), (int) Math.round(pixelDensity.getRawVerticalDensity()), (byte) 0); 392 } else { 393 writeChunkPHYS(os, (int) Math.round(pixelDensity.horizontalDensityMetres()), (int) Math.round(pixelDensity.verticalDensityMetres()), (byte) 1); 394 } 395 } 396 397 final PhysicalScale physicalScale = params.getPhysicalScale(); 398 if (physicalScale != null) { 399 writeChunkSCAL(os, physicalScale.getHorizontalUnitsPerPixel(), physicalScale.getVerticalUnitsPerPixel(), 400 physicalScale.isInMeters() ? (byte) 1 : (byte) 2); 401 } 402 403 final String xmpXml = params.getXmpXml(); 404 if (xmpXml != null) { 405 writeChunkXmpiTXt(os, xmpXml); 406 } 407 408 final List<? extends AbstractPngText> outputTexts = params.getTextChunks(); 409 if (outputTexts != null) { 410 for (final AbstractPngText text : outputTexts) { 411 if (text instanceof AbstractPngText.Text) { 412 writeChunktEXt(os, (AbstractPngText.Text) text); 413 } else if (text instanceof AbstractPngText.Ztxt) { 414 writeChunkzTXt(os, (AbstractPngText.Ztxt) text); 415 } else if (text instanceof AbstractPngText.Itxt) { 416 writeChunkiTXt(os, (AbstractPngText.Itxt) text); 417 } else { 418 throw new ImagingException("Unknown text to embed in PNG: " + text); 419 } 420 } 421 } 422 423 { 424 // Debug.debug("writing IDAT"); 425 426 // IDAT Yes Multiple IDAT chunks shall be consecutive 427 428 // 28 March 2022. At this time, we only apply the predictor 429 // for non-grayscale, true-color images. This choice is made 430 // out of caution and is not necessarily required by the PNG 431 // spec. We may broaden the use of predictors in future versions. 432 final boolean usePredictor = params.isPredictorEnabled() && !isGrayscale && palette == null; 433 434 byte[] uncompressed; 435 if (!usePredictor) { 436 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 437 438 final boolean useAlpha = pngColorType == PngColorType.GREYSCALE_WITH_ALPHA || pngColorType == PngColorType.TRUE_COLOR_WITH_ALPHA; 439 440 final int[] row = Allocator.intArray(width); 441 for (int y = 0; y < height; y++) { 442 // Debug.debug("y", y + "/" + height); 443 src.getRGB(0, y, width, 1, row, 0, width); 444 445 baos.write(FilterType.NONE.ordinal()); 446 for (int x = 0; x < width; x++) { 447 final int argb = row[x]; 448 449 if (palette != null) { 450 final int index = palette.getPaletteIndex(argb); 451 baos.write(0xff & index); 452 } else { 453 final int alpha = 0xff & argb >> 24; 454 final int red = 0xff & argb >> 16; 455 final int green = 0xff & argb >> 8; 456 final int blue = 0xff & argb >> 0; 457 458 if (isGrayscale) { 459 final int gray = (red + green + blue) / 3; 460 // if (y == 0) 461 // { 462 // Debug.debug("gray: " + x + ", " + y + 463 // " argb: 0x" 464 // + Integer.toHexString(argb) + " gray: 0x" 465 // + Integer.toHexString(gray)); 466 // // Debug.debug(x + ", " + y + " gray", gray); 467 // // Debug.debug(x + ", " + y + " gray", gray); 468 // Debug.debug(x + ", " + y + " gray", gray + 469 // " " + Integer.toHexString(gray)); 470 // Debug.debug(); 471 // } 472 baos.write(gray); 473 } else { 474 baos.write(red); 475 baos.write(green); 476 baos.write(blue); 477 } 478 if (useAlpha) { 479 baos.write(alpha); 480 } 481 } 482 } 483 } 484 uncompressed = baos.toByteArray(); 485 } else { 486 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 487 488 final boolean useAlpha = pngColorType == PngColorType.GREYSCALE_WITH_ALPHA || pngColorType == PngColorType.TRUE_COLOR_WITH_ALPHA; 489 490 final int[] row = Allocator.intArray(width); 491 for (int y = 0; y < height; y++) { 492 // Debug.debug("y", y + "/" + height); 493 src.getRGB(0, y, width, 1, row, 0, width); 494 495 int priorA = 0; 496 int priorR = 0; 497 int priorG = 0; 498 int priorB = 0; 499 baos.write(FilterType.SUB.ordinal()); 500 for (int x = 0; x < width; x++) { 501 final int argb = row[x]; 502 final int alpha = 0xff & argb >> 24; 503 final int red = 0xff & argb >> 16; 504 final int green = 0xff & argb >> 8; 505 final int blue = 0xff & argb; 506 507 baos.write(red - priorR); 508 baos.write(green - priorG); 509 baos.write(blue - priorB); 510 priorR = red; 511 priorG = green; 512 priorB = blue; 513 514 if (useAlpha) { 515 baos.write(alpha - priorA); 516 priorA = alpha; 517 } 518 } 519 } 520 uncompressed = baos.toByteArray(); 521 } 522 523 // Debug.debug("uncompressed", uncompressed.length); 524 525 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 526 final int chunkSize = 256 * 1024; 527 final Deflater deflater = new Deflater(compressionLevel); 528 final DeflaterOutputStream dos = new DeflaterOutputStream(baos, deflater, chunkSize); 529 530 for (int index = 0; index < uncompressed.length; index += chunkSize) { 531 final int end = Math.min(uncompressed.length, index + chunkSize); 532 final int length = end - index; 533 534 dos.write(uncompressed, index, length); 535 dos.flush(); 536 baos.flush(); 537 538 final byte[] compressed = baos.toByteArray(); 539 baos.reset(); 540 if (compressed.length > 0) { 541 // Debug.debug("compressed", compressed.length); 542 writeChunkIDAT(os, compressed); 543 } 544 545 } 546 { 547 dos.finish(); 548 final byte[] compressed = baos.toByteArray(); 549 if (compressed.length > 0) { 550 // Debug.debug("compressed final", compressed.length); 551 writeChunkIDAT(os, compressed); 552 } 553 } 554 } 555 556 { 557 // IEND No Shall be last 558 559 writeChunkIEND(os); 560 } 561 562 /* 563 * Ancillary chunks (need not appear in this order) Chunk name Multiple allowed Ordering constraints cHRM No Before PLTE and IDAT gAMA No Before PLTE 564 * and IDAT iCCP No Before PLTE and IDAT. If the iCCP chunk is present, the sRGB chunk should not be present. sBIT No Before PLTE and IDAT sRGB No 565 * Before PLTE and IDAT. If the sRGB chunk is present, the iCCP chunk should not be present. bKGD No After PLTE; before IDAT hIST No After PLTE; before 566 * IDAT tRNS No After PLTE; before IDAT pHYs No Before IDAT sCAL No Before IDAT sPLT Yes Before IDAT tIME No None iTXt Yes None tEXt Yes None zTXt Yes 567 * None 568 */ 569 570 os.close(); 571 } // todo: filter types 572 // proper color types 573 // srgb, etc. 574 575 private void writeInt(final OutputStream os, final int value) throws IOException { 576 os.write(0xff & value >> 24); 577 os.write(0xff & value >> 16); 578 os.write(0xff & value >> 8); 579 os.write(0xff & value >> 0); 580 } 581}