PngChunks.java

/* ========================================================================
 * PlantUML : a free UML diagram generator
 * ========================================================================
 *
 * (C) Copyright 2009-2025, Arnaud Roques
 *
 * Project Info:  https://plantuml.com
 *
 * If you like this project or if you find it useful, you can support us at:
 *
 * https://plantuml.com/patreon (only 1$ per month!)
 * https://plantuml.com/paypal
 *
 * This file is part of PlantUML.
 *
 * PlantUML is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * PlantUML distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
 * License for more details.
 *
 * You should have received a copy of the GNU General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
 * USA.
 *
 *
 * Original Author:  Arnaud Roques
 *
 */
package net.sourceforge.plantuml.klimt.drawing.raster;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.zip.CRC32;
import java.util.zip.Deflater;

/**
 * Adds to a PNG the chunks PlantUML writes in its PNG images (see
 * {@code PngIOMetadata}): the copyleft text, the source of the diagram
 * (compressed iTXt "plantuml", read back by {@code -metadata}), and the
 * resolution when it is not 96 dpi.
 */
public final class PngChunks {

	private static final String COPYLEFT = "Generated by https://plantuml.com";

	private static final byte[] SIGNATURE = { (byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n' };

	private PngChunks() {
	}

	/**
	 * @param png      a PNG starting with its IHDR chunk, as encoders write it
	 * @param metadata the source of the diagram, or null
	 * @param dpi      the resolution of the image
	 */
	public static byte[] addPlantUmlChunks(byte[] png, String metadata, int dpi) throws IOException {
		for (int i = 0; i < SIGNATURE.length; i++)
			if (png.length < SIGNATURE.length || png[i] != SIGNATURE[i])
				throw new IOException("Not a PNG");
		// The IHDR chunk always comes first: 4 (length) + 4 (type) + 13 + 4 (CRC)
		final int afterHeader = SIGNATURE.length + 4 + 4 + 13 + 4;
		if (png.length < afterHeader || "IHDR".equals(new String(png, 12, 4, StandardCharsets.ISO_8859_1)) == false)
			throw new IOException("No IHDR chunk");

		final ByteArrayOutputStream result = new ByteArrayOutputStream(png.length + 1024);
		result.write(png, 0, afterHeader);
		// Same order as ImageIO: pHYs, tEXt, iTXt, before the image data
		if (dpi != 96)
			writeChunk(result, "pHYs", physicalDimensions(dpi));
		writeChunk(result, "tEXt", text("copyleft", COPYLEFT));
		if (metadata != null)
			writeChunk(result, "iTXt", compressedInternationalText("plantuml", metadata));
		result.write(png, afterHeader, png.length - afterHeader);
		return result.toByteArray();
	}

	private static byte[] physicalDimensions(int dpi) {
		final long pixelsPerMeter = Math.round(dpi / 0.0254);
		final ByteArrayOutputStream data = new ByteArrayOutputStream();
		writeInt(data, (int) pixelsPerMeter);
		writeInt(data, (int) pixelsPerMeter);
		data.write(1); // unit: meter
		return data.toByteArray();
	}

	private static byte[] text(String keyword, String value) {
		final ByteArrayOutputStream data = new ByteArrayOutputStream();
		final byte[] key = keyword.getBytes(StandardCharsets.ISO_8859_1);
		data.write(key, 0, key.length);
		data.write(0);
		final byte[] text = value.getBytes(StandardCharsets.ISO_8859_1);
		data.write(text, 0, text.length);
		return data.toByteArray();
	}

	private static byte[] compressedInternationalText(String keyword, String value) {
		final ByteArrayOutputStream data = new ByteArrayOutputStream();
		final byte[] key = keyword.getBytes(StandardCharsets.ISO_8859_1);
		data.write(key, 0, key.length);
		data.write(0);
		data.write(1); // compression flag
		data.write(0); // compression method: zlib
		data.write(0); // empty language tag
		data.write(0); // empty translated keyword
		final byte[] compressed = deflate(value.getBytes(StandardCharsets.UTF_8));
		data.write(compressed, 0, compressed.length);
		return data.toByteArray();
	}

	private static byte[] deflate(byte[] input) {
		final Deflater deflater = new Deflater();
		try {
			deflater.setInput(input);
			deflater.finish();
			final ByteArrayOutputStream result = new ByteArrayOutputStream(input.length / 2 + 64);
			final byte[] buffer = new byte[8192];
			while (deflater.finished() == false) {
				final int n = deflater.deflate(buffer);
				result.write(buffer, 0, n);
			}
			return result.toByteArray();
		} finally {
			deflater.end();
		}
	}

	private static void writeChunk(ByteArrayOutputStream os, String type, byte[] data) {
		final byte[] typeBytes = type.getBytes(StandardCharsets.ISO_8859_1);
		writeInt(os, data.length);
		os.write(typeBytes, 0, typeBytes.length);
		os.write(data, 0, data.length);
		final CRC32 crc = new CRC32();
		crc.update(typeBytes);
		crc.update(data);
		writeInt(os, (int) crc.getValue());
	}

	private static void writeInt(ByteArrayOutputStream os, int value) {
		os.write(value >>> 24);
		os.write(value >>> 16);
		os.write(value >>> 8);
		os.write(value);
	}

}