PdfGraphics.java

/* ========================================================================
 * PlantUML : a free UML diagram generator
 * ========================================================================
 *
 * (C) Copyright 2009-2026, 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.openpdf;

import java.awt.Color;
import java.awt.geom.PathIterator;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.LinkedList;

import org.openpdf.text.BadElementException;
import org.openpdf.text.Document;
import org.openpdf.text.Image;
import org.openpdf.text.Rectangle;
import org.openpdf.text.pdf.BaseFont;
import org.openpdf.text.pdf.PdfAction;
import org.openpdf.text.pdf.PdfAnnotation;
import org.openpdf.text.pdf.PdfContentByte;
import org.openpdf.text.pdf.PdfTemplate;
import org.openpdf.text.pdf.PdfWriter;

import net.sourceforge.plantuml.klimt.UPath;
import net.sourceforge.plantuml.klimt.awt.PortableImage;
import net.sourceforge.plantuml.klimt.color.HColor.TransparentFillBehavior;
import net.sourceforge.plantuml.klimt.geom.USegment;
import net.sourceforge.plantuml.klimt.geom.USegmentType;

/**
 * PDF backend that mirrors the public API of
 * {@code net.sourceforge.plantuml.klimt.drawing.svg.SvgGraphics}.
 *
 * <h2>Coordinate convention</h2>
 *
 * PDF natively has Y growing upwards from a bottom-left origin; SVG and
 * PlantUML have Y growing downwards from a top-left origin. Rather than
 * flipping the CTM (which forces every text run and image to compensate with a
 * local flip), this driver keeps PDF's native Y-up convention and simply
 * <strong>negates Y in input</strong>: a caller-supplied SVG-coord
 * {@code (x, y)} is drawn at PDF-coord {@code (x, -y)}.
 *
 * <p>
 * For shapes that take a top-left corner plus a size (rectangles, raster
 * images), the bottom-left in PDF coords becomes {@code (x, -(y + height))}.
 * For point-like primitives (line endpoints, polygon vertices, ellipse centers,
 * text baselines, path segments) it is simply {@code (x, -y)}.
 *
 * <h2>From negative-Y back to a normal page</h2>
 *
 * The content is buffered into a {@link PdfTemplate} (a PDF form XObject) while
 * we draw. The template is unbounded - it can hold negative-Y content without
 * complaining. At {@link #createPdf(OutputStream)} time we know the final
 * extent ({@code maxX}, {@code maxY} in SVG coords): we open a page of size
 * {@code maxX x maxY} and place the template translated by {@code (0, maxY)} so
 * the content lands inside {@code [0, 0, maxX, maxY]}.
 *
 * <p>
 * Net result for callers: SVG-style coords on the way in, a standard-shape page
 * on the way out, no CTM flip anywhere, no per-call compensation for text or
 * images.
 *
 * <h2>Helper {@link #ny(double)}</h2>
 *
 * All internal conversions go through {@link #ny(double)} (read it as "negate
 * y"). If anything ever looks upside-down or misplaced in a generated PDF, the
 * one place to look is whether a coordinate took the {@code ny()} route or
 * leaked through as a positive PDF coord.
 *
 * <h2>Deliberately omitted in v1</h2>
 *
 * Drop shadows, embedded SVG images (stdlib icons), gradients, text-background
 * filters, rotated text, CSS classes, interactive scripts, dark mode.
 *
 * <h2>Not for TeaVM</h2>
 *
 * OpenPDF depends on {@code java.awt} and {@code java.util.zip}; this driver is
 * server-side only and is excluded from the TeaVM build path.
 *
 * <h2>OpenPDF version</h2>
 *
 * Targets OpenPDF 3.0+ which uses the {@code org.openpdf} package namespace.
 * The legacy {@code com.lowagie} packages have been removed in 3.0 and are not
 * available anymore.
 */
public class PdfGraphics {
	// ::remove folder when JAVA8

	// ---------- output buffering ----------

	private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
	private final Document document;
	private final PdfWriter writer;
	private PdfTemplate template;
	private PdfContentByte cb;
	private boolean documentOpened = false;

	// ---------- options ----------

	private final PdfOption option;

	// ---------- bounding box tracking (in caller-facing SVG coords, Y > 0) ----

	private double maxX;
	private double maxY;

	// ---------- current graphics state ----------

	private Color fillColor = Color.BLACK;
	private Color strokeColor = Color.BLACK;
	private double strokeWidth = 1.0;
	private float[] strokeDash = null;
	private boolean hidden = false;
	private boolean pathOpen = false;

	// ---------- pending links (for openLink/closeLink) ----------

	private final LinkedList<PendingLink> activeLinks = new LinkedList<>();

	// ----------------------------------------------------------------------
	// construction
	// ----------------------------------------------------------------------

	public PdfGraphics(PdfOption option) {
		this.option = option == null ? new PdfOption() : option;
		this.maxX = this.option.getMinWidth();
		this.maxY = this.option.getMinHeight();

		// We do not know the final page size yet. Open with an arbitrary
		// placeholder (we'll fix the actual page size in createPdf, before
		// document.open()). All drawing goes into a PdfTemplate which is
		// unbounded - it can hold negative-Y content without truncation.
		this.document = new Document(new Rectangle(10, 10));
		this.writer = PdfWriter.getInstance(document, buffer);
	}

	private void openDocumentIfNeeded() {
		if (documentOpened)
			return;
		documentOpened = true;

		if (option.getTitle() != null)
			document.addTitle(option.getTitle());
		if (option.getAuthor() != null)
			document.addAuthor(option.getAuthor());
		if (option.getSubject() != null)
			document.addSubject(option.getSubject());

		// Page size will be patched in createPdf. Open document with the
		// placeholder; we draw exclusively into the template so the page's
		// own size does not constrain us.
		//
		// IMPORTANT: the template has a *bounding box* and content outside
		// that box is clipped by the viewer. Since we draw in negative Y,
		// the bbox must extend into negative Y too. We set it to a generous
		// 10000 x 10000 region covering [0, 10000] x [-10000, 0]; createPdf
		// later tightens it to the actual content bounds.
		document.open();
		final PdfContentByte direct = writer.getDirectContent();
		this.template = direct.createTemplate(10000, 10000);
		this.template.setBoundingBox(new Rectangle(0f, -10000f, 10000f, 0f));
		this.cb = template;
	}

	// ----------------------------------------------------------------------
	// the one-line "negate Y" helper
	// ----------------------------------------------------------------------

	/**
	 * Convert a caller-facing SVG Y (positive, grows downward) into the PDF Y we
	 * actually emit (negative, drawn below the y=0 axis).
	 */
	private static float ny(double svgY) {
		return (float) -svgY;
	}

	// ----------------------------------------------------------------------
	// bounding box
	// ----------------------------------------------------------------------

	/**
	 * Tracked in SVG coords (positive Y). {@code maxX} and {@code maxY} are the
	 * largest values the caller has drawn at; the final page will be a
	 * {@code maxX x maxY} rectangle in standard PDF coords.
	 */
	protected final void ensureVisible(double x, double y) {
		if (x > maxX)
			maxX = x + 1;
		if (y > maxY)
			maxY = y + 1;
	}

	// ----------------------------------------------------------------------
	// state setters (mirror SvgGraphics)
	// ----------------------------------------------------------------------

	public final void setFillColor(Color fill) {
		this.fillColor = fill;
	}

	public final void setFillColor(Color fill, TransparentFillBehavior transparentFillBehaviour) {
		this.fillColor = fill;
	}

	public final void setStrokeColor(Color stroke) {
		this.strokeColor = stroke;
	}

	public final void setStrokeWidth(double width, double[] dash) {
		this.strokeWidth = width;
		if (dash == null) {
			this.strokeDash = null;
		} else {
			this.strokeDash = new float[dash.length];
			for (int i = 0; i < dash.length; i++)
				this.strokeDash[i] = (float) dash[i];
		}
	}

	public void setHidden(boolean hidden) {
		this.hidden = hidden;
	}

	// ----------------------------------------------------------------------
	// internal helpers
	// ----------------------------------------------------------------------

	private void applyStroke() {
		cb.setLineWidth((float) strokeWidth);
		if (strokeColor != null)
			cb.setColorStroke(strokeColor);
		if (strokeDash == null)
			cb.setLineDash(0);
		else if (strokeDash.length >= 2)
			cb.setLineDash(strokeDash[0], strokeDash[1], 0);
		else
			cb.setLineDash(strokeDash[0], strokeDash[0], 0);
	}

	private void applyFill() {
		if (fillColor != null)
			cb.setColorFill(fillColor);
	}

	private boolean hasFill() {
		return fillColor != null && fillColor.getAlpha() > 0;
	}

	private boolean hasStroke() {
		return strokeColor != null && strokeColor.getAlpha() > 0 && strokeWidth > 0;
	}

	private void paintPath() {
		final boolean f = hasFill();
		final boolean s = hasStroke();
		if (f && s)
			cb.fillStroke();
		else if (f)
			cb.fill();
		else if (s)
			cb.stroke();
		else
			cb.newPath();
	}

	// ----------------------------------------------------------------------
	// shape primitives (mirror SvgGraphics)
	// ----------------------------------------------------------------------

	public void pdfRectangle(double x, double y, double width, double height, double rx, double ry) {
		if (width <= 0 || height <= 0)
			return;
		if (hidden)
			return;
		openDocumentIfNeeded();
		applyStroke();
		applyFill();
		// PDF rectangle takes its lower-left corner. In our (Y-negated) world,
		// the SVG top-left (x, y) becomes the PDF *upper*-left at (x, -y),
		// which means the lower-left is at (x, -y - height) = (x, -(y+height)).
		if (rx > 0 && ry > 0)
			cb.roundRectangle((float) x, ny(y + height), (float) width, (float) height, (float) Math.min(rx, ry));
		else
			cb.rectangle((float) x, ny(y + height), (float) width, (float) height);
		paintPath();
		ensureVisible(x + width, y + height);
	}

	public void pdfLine(double x1, double y1, double x2, double y2) {
		if (hidden)
			return;
		openDocumentIfNeeded();
		applyStroke();
		cb.moveTo((float) x1, ny(y1));
		cb.lineTo((float) x2, ny(y2));
		cb.stroke();
		ensureVisible(x1, y1);
		ensureVisible(x2, y2);
	}

	public void pdfEllipse(double cx, double cy, double rx, double ry) {
		if (hidden)
			return;
		openDocumentIfNeeded();
		applyStroke();
		applyFill();
		// PdfContentByte.ellipse takes a bounding box (x1, y1, x2, y2). With
		// Y negated, the box runs from (cx - rx, -cy - ry) to (cx + rx, -cy + ry).
		cb.ellipse((float) (cx - rx), ny(cy + ry), (float) (cx + rx), ny(cy - ry));
		paintPath();
		ensureVisible(cx + rx, cy + ry);
	}

	public void pdfArcEllipse(double rx, double ry, double x1, double y1, double x2, double y2) {
		if (hidden)
			return;
		openDocumentIfNeeded();

		applyStroke();
		applyFill();

		// Mirrors the SVG path "M x1,y1 A rx,ry 0 0 0 x2,y2" emitted by the
		// original SvgGraphics.svgArcEllipse: xAxisRotation=0, largeArc=0,
		// sweep=0. DriverEllipsePdf has already picked which endpoint goes
		// first based on the sign of `extend`, so we can keep flags fixed.
		cb.moveTo((float) x1, ny(y1));
		arcToCubics(x1, y1, rx, ry, /* phiDeg */ 0, /* largeArc */ false, /* sweep */ false, x2, y2);
		paintPath();

		ensureVisible(x1, y1);
		ensureVisible(x2, y2);
	}

	public void pdfPolygon(double... points) {
		if (points.length < 4 || (points.length & 1) != 0)
			return;
		if (hidden)
			return;
		openDocumentIfNeeded();
		applyStroke();
		applyFill();
		cb.moveTo((float) points[0], ny(points[1]));
		for (int i = 2; i < points.length; i += 2)
			cb.lineTo((float) points[i], ny(points[i + 1]));
		cb.closePath();
		paintPath();
		for (int i = 0; i < points.length; i += 2)
			ensureVisible(points[i], points[i + 1]);
	}

	// ----------------------------------------------------------------------
	// path drawing (newpath/moveto/.../fill)
	// ----------------------------------------------------------------------

	public void newpath() {
		openDocumentIfNeeded();
		applyStroke();
		applyFill();
		pathOpen = true;
	}

	public void moveto(double x, double y) {
		cb.moveTo((float) x, ny(y));
		ensureVisible(x, y);
	}

	public void lineto(double x, double y) {
		cb.lineTo((float) x, ny(y));
		ensureVisible(x, y);
	}

	public void closepath() {
		cb.closePath();
	}

	public void curveto(double x1, double y1, double x2, double y2, double x3, double y3) {
		cb.curveTo((float) x1, ny(y1), (float) x2, ny(y2), (float) x3, ny(y3));
		ensureVisible(x1, y1);
		ensureVisible(x2, y2);
		ensureVisible(x3, y3);
	}

	/**
	 * Quadratic bezier. PDF does not have a native quad operator, so we convert to
	 * a cubic on the fly using the standard formula
	 * {@code C1 = P0 + 2/3*(P1-P0), C2 = P2 + 2/3*(P1-P2)}. The previous current
	 * point on the path is needed - we accept it as input because we do not track
	 * the path cursor ourselves.
	 *
	 * Note: the conversion is done in SVG coords for clarity, then Y is negated at
	 * the very last moment when calling curveTo. Negating before or after the
	 * convex combination gives the same result mathematically, but doing it last
	 * keeps the formula readable.
	 */
	public void quadto(double x0, double y0, double x1, double y1, double x2, double y2) {
		final double c1x = x0 + 2.0 / 3.0 * (x1 - x0);
		final double c1y = y0 + 2.0 / 3.0 * (y1 - y0);
		final double c2x = x2 + 2.0 / 3.0 * (x1 - x2);
		final double c2y = y2 + 2.0 / 3.0 * (y1 - y2);
		cb.curveTo((float) c1x, ny(c1y), (float) c2x, ny(c2y), (float) x2, ny(y2));
		ensureVisible(x1, y1);
		ensureVisible(x2, y2);
	}

	public void fill(int windingRule) {
		if (!pathOpen)
			return;
		pathOpen = false;
		final boolean f = hasFill();
		final boolean s = hasStroke();
		if (windingRule == PathIterator.WIND_EVEN_ODD) {
			if (f && s)
				cb.eoFillStroke();
			else if (f)
				cb.eoFill();
			else if (s)
				cb.stroke();
			else
				cb.newPath();
		} else {
			if (f && s)
				cb.fillStroke();
			else if (f)
				cb.fill();
			else if (s)
				cb.stroke();
			else
				cb.newPath();
		}
	}

	/**
	 * Replay a Java2D {@link PathIterator} into the current PDF stream. Used by the
	 * higher-level driver to handle arbitrary AWT shapes (including arcs already
	 * flattened to bezier by Java2D).
	 *
	 * All coordinates go through the regular path API ({@link #moveto},
	 * {@link #lineto}, {@link #quadto}, {@link #curveto}), so Y negation happens
	 * transparently there.
	 */
	public void drawPathIterator(double x, double y, PathIterator path) {
		newpath();
		final double[] coord = new double[6];
		double curX = 0, curY = 0;
		while (!path.isDone()) {
			final int code = path.currentSegment(coord);
			switch (code) {
			case PathIterator.SEG_MOVETO:
				moveto(coord[0] + x, coord[1] + y);
				curX = coord[0] + x;
				curY = coord[1] + y;
				break;
			case PathIterator.SEG_LINETO:
				lineto(coord[0] + x, coord[1] + y);
				curX = coord[0] + x;
				curY = coord[1] + y;
				break;
			case PathIterator.SEG_QUADTO:
				quadto(curX, curY, coord[0] + x, coord[1] + y, coord[2] + x, coord[3] + y);
				curX = coord[2] + x;
				curY = coord[3] + y;
				break;
			case PathIterator.SEG_CUBICTO:
				curveto(coord[0] + x, coord[1] + y, coord[2] + x, coord[3] + y, coord[4] + x, coord[5] + y);
				curX = coord[4] + x;
				curY = coord[5] + y;
				break;
			case PathIterator.SEG_CLOSE:
				closepath();
				break;
			default:
				throw new UnsupportedOperationException("code=" + code);
			}
			path.next();
		}
		fill(path.getWindingRule());
	}

	// ----------------------------------------------------------------------
	// text
	// ----------------------------------------------------------------------

	/**
	 * Render a single line of text at the (x, y) baseline position.
	 *
	 * With Y negation, the default PDF text matrix (which renders glyphs
	 * right-side-up) is what we want - no per-call flip is needed. We just use
	 * {@link PdfContentByte#setTextMatrix(float, float)} to place the baseline at
	 * {@code (x, -y)}.
	 */
	public void pdfText(String text, double x, double y, BaseFont font, int fontSize) {
		if (hidden)
			return;
		if (text == null || text.isEmpty() || fontSize == 0)
			return;
		openDocumentIfNeeded();

		cb.beginText();
		cb.setColorFill(fillColor != null ? fillColor : Color.BLACK);
		cb.setFontAndSize(font, fontSize);

		// Default text matrix (identity, glyphs upright). Just place the
		// baseline at (x, -y).
		cb.setTextMatrix((float) x, ny(y));

		cb.showText(text);
		cb.endText();

		ensureVisible(x + font.getWidthPoint(text, fontSize), y);
	}

	// ----------------------------------------------------------------------
	// images (PNG / JPEG)
	// ----------------------------------------------------------------------

	/**
	 * Insert a raster image (PNG/JPEG bytes) at position (x, y).
	 *
	 * With Y negation, the image's bottom-left in PDF coords lands at
	 * {@code (x, -(y + height))} - same formula as for rectangles. No local matrix
	 * flip is needed; the image stays right-side-up.
	 *
	 * @param pngOrJpegBytes encoded image data.
	 * @param x              top-left X (SVG convention).
	 * @param y              top-left Y (SVG convention).
	 * @param width          target render width.
	 * @param height         target render height.
	 */
	public void pdfImage(byte[] pngOrJpegBytes, double x, double y, double width, double height) {
		if (hidden)
			return;
		openDocumentIfNeeded();
		try {
			final Image img = Image.getInstance(pngOrJpegBytes);
			img.scaleAbsolute((float) width, (float) height);
			img.setAbsolutePosition((float) x, ny(y + height));
			cb.addImage(img);
		} catch (Exception e) {
			throw new IllegalStateException("Cannot embed image", e);
		}
		ensureVisible(x + width, y + height);
	}
	
	/**
	 * Insert a raster {@link BufferedImage} at position (x, y), rendered at its
	 * natural pixel size (1 px = 1 user unit). Used by {@code DriverImagePdf} for
	 * {@link net.sourceforge.plantuml.klimt.shape.UImage} content.
	 *
	 * <p>
	 * Handed straight to OpenPDF via
	 * {@code Image.getInstance(java.awt.Image, Color, boolean)}: passing a
	 * {@code null} mask color makes OpenPDF derive the transparency mask from the
	 * image's own alpha channel (PlantUML icons and sprites rely on it), and
	 * {@code forceBW=false} keeps the colors. Positioning and Y-negation follow the
	 * same convention as {@link #pdfImage(byte[], double, double, double, double)}.
	 *
	 * @param bufferedImage the image to embed.
	 * @param x             top-left X (SVG convention).
	 * @param y             top-left Y (SVG convention).
	 * @throws IOException if OpenPDF cannot build the image.
	 */
	public void pdfImage(BufferedImage bufferedImage, double x, double y) throws IOException {
		if (hidden)
			return;
		if (bufferedImage == null)
			return;
		openDocumentIfNeeded();
		final int width = bufferedImage.getWidth();
		final int height = bufferedImage.getHeight();
		if (width <= 0 || height <= 0)
			return;
		try {
			final Image img = Image.getInstance(bufferedImage, null, false);
			img.setAbsolutePosition((float) x, ny(y + height));
			cb.addImage(img);
		} catch (BadElementException e) {
			throw new IOException("Cannot embed image", e);
		}
		ensureVisible(x + width, y + height);
	}


	// ----------------------------------------------------------------------
	// links
	// ----------------------------------------------------------------------

	/**
	 * A pending link region. Bounds are tracked in <em>PDF</em> coords (Y already
	 * negated) because the {@link PdfAnnotation} we eventually emit must match the
	 * page coordinate system.
	 */
	private static final class PendingLink {
		final String url;
		final String title;
		double llx = Double.POSITIVE_INFINITY;
		double lly = Double.POSITIVE_INFINITY;
		double urx = Double.NEGATIVE_INFINITY;
		double ury = Double.NEGATIVE_INFINITY;

		PendingLink(String url, String title) {
			this.url = url;
			this.title = title;
		}

		void expand(double x, double pdfY) {
			if (x < llx)
				llx = x;
			if (pdfY < lly)
				lly = pdfY;
			if (x > urx)
				urx = x;
			if (pdfY > ury)
				ury = pdfY;
		}
	}

	/**
	 * Begin a clickable region. Currently the bounding box of the link is driven
	 * externally (callers must drive {@link #ensureVisible} - the higher-level
	 * UGraphic driver does that anyway). A future iteration may auto-track inside
	 * {@code openLink/closeLink} by intercepting every drawing primitive between
	 * the two calls.
	 *
	 * Unlike SVG, PDF link annotations are flat rectangles - we cannot nest them.
	 * Nested calls do behave correctly because we keep a stack of
	 * {@link PendingLink}, but they will produce sibling rectangles rather than
	 * truly nested clickable regions.
	 */
	public void openLink(String url, String title) {
		openDocumentIfNeeded();
		activeLinks.push(new PendingLink(url, title));
	}

	public void closeLink() {
		if (activeLinks.isEmpty())
			throw new IllegalStateException("closeLink() with no matching openLink()");
		final PendingLink link = activeLinks.pop();
		if (link.urx <= link.llx || link.ury <= link.lly)
			return; // nothing was drawn in this region
		final PdfAction action = new PdfAction(link.url);
		final PdfAnnotation annot = PdfAnnotation.createLink(writer,
				new Rectangle((float) link.llx, (float) link.lly, (float) link.urx, (float) link.ury),
				PdfAnnotation.HIGHLIGHT_INVERT, action);
		writer.addAnnotation(annot);
	}

	// ----------------------------------------------------------------------
	// output
	// ----------------------------------------------------------------------

	/**
	 * Flush the buffered drawing to the supplied stream as a complete PDF.
	 *
	 * Pipeline:
	 * <ol>
	 * <li>Compute the final page size from {@code maxX} and {@code maxY}.</li>
	 * <li>Switch the document's page size to {@code maxX x maxY} and start a new
	 * page (the placeholder page we opened with is replaced).</li>
	 * <li>Place the {@link PdfTemplate} on that page with a translation of
	 * {@code (0, maxY)}: our negative-Y content moves into
	 * {@code [0, 0, maxX, maxY]} in page coords.</li>
	 * </ol>
	 *
	 * After this call the instance must not be used anymore.
	 */
	public void createPdf(OutputStream os) throws IOException {
		openDocumentIfNeeded();

		final float scaledW = (float) (maxX * option.getScale());
		final float scaledH = (float) (maxY * option.getScale());

		// Tighten the template's bounding box to the actual content extent.
		// Our content lives in [0, maxX] x [-maxY, 0].
		template.setBoundingBox(new Rectangle(0f, -scaledH, scaledW, 0f));

		// Switch to the real page size. setPageSize affects subsequent pages,
		// so we call newPage() to flush the placeholder page and start a new
		// one with the proper dimensions.
		final Rectangle pageSize = new Rectangle(scaledW, scaledH);
		document.setPageSize(pageSize);
		document.newPage();

		// Place the template, translated up by maxY so the content (which
		// lives in negative Y inside the template) lands in [0, 0, w, h].
		final PdfContentByte pageContent = writer.getDirectContent();
		pageContent.addTemplate(template, 0, scaledH);

		document.close();
		buffer.writeTo(os);
	}

	// ----------------------------------------------------------------------
	// raw access (escape hatch for callers that need PdfContentByte)
	// ----------------------------------------------------------------------

	/**
	 * Direct access to the underlying OpenPDF content stream. Intended for advanced
	 * cases (custom shading patterns, form XObjects, ...). Most callers should
	 * never need this.
	 *
	 * <strong>Coordinate warning:</strong> raw drawing through this stream uses
	 * native PDF coords (Y-up). Either work entirely in PDF coords, or negate Y
	 * yourself with the same convention this class uses ({@code pdfY = -svgY}).
	 */
	public PdfContentByte getRawContent() {
		openDocumentIfNeeded();
		return cb;
	}

	public PdfWriter getRawWriter() {
		openDocumentIfNeeded();
		return writer;
	}

	// ----------------------------------------------------------------------
	// UPath rendering
	// ----------------------------------------------------------------------

	/**
	 * Render a {@link UPath} (PlantUML's SVG-flavored path representation)
	 * translated by {@code (x, y)}.
	 *
	 * Each {@link USegment} maps to a native PDF path operator, except
	 * {@link USegmentType#SEG_ARCTO} which is converted on the fly to a sequence of
	 * cubic Beziers (PDF has no arc operator). The conversion follows the SVG 1.1
	 * Appendix F.6 endpoint-to-center parametrization and approximates each ~90
	 * degree slice with one cubic.
	 *
	 * <p>
	 * Path metadata ({@code comment}, {@code codeLine}) is currently ignored: PDF
	 * has no clean place to expose it (no element id like SVG). A future iteration
	 * could emit it as a marked-content tag if useful for accessibility or tooling.
	 */
	public void pdfPath(double x, double y, UPath path) {
		if (hidden)
			return;
		openDocumentIfNeeded();
		ensureVisible(x, y);

		applyStroke();
		applyFill();

		// Track the current point so we can feed quad-to-cubic conversion and
		// the arc converter, which both need the previous endpoint.
		double curX = x;
		double curY = y;

		for (USegment seg : path) {
			final USegmentType type = seg.getSegmentType();
			final double[] c = seg.getCoord();

			if (type == USegmentType.SEG_MOVETO) {
				final double px = c[0] + x;
				final double py = c[1] + y;
				cb.moveTo((float) px, ny(py));
				ensureVisible(px, py);
				curX = px;
				curY = py;

			} else if (type == USegmentType.SEG_LINETO) {
				final double px = c[0] + x;
				final double py = c[1] + y;
				cb.lineTo((float) px, ny(py));
				ensureVisible(px, py);
				curX = px;
				curY = py;

			} else if (type == USegmentType.SEG_QUADTO) {
				// UPath.quadTo stores the control point twice; we use one pair
				// as the quadratic control and convert to a cubic for PDF.
				final double cx1 = c[0] + x;
				final double cy1 = c[1] + y;
				final double px = c[4] + x;
				final double py = c[5] + y;
				quadToInternal(curX, curY, cx1, cy1, px, py);
				ensureVisible(px, py);
				curX = px;
				curY = py;

			} else if (type == USegmentType.SEG_CUBICTO) {
				final double cx1 = c[0] + x;
				final double cy1 = c[1] + y;
				final double cx2 = c[2] + x;
				final double cy2 = c[3] + y;
				final double px = c[4] + x;
				final double py = c[5] + y;
				cb.curveTo((float) cx1, ny(cy1), (float) cx2, ny(cy2), (float) px, ny(py));
				ensureVisible(px, py);
				curX = px;
				curY = py;

			} else if (type == USegmentType.SEG_ARCTO) {
				// SVG arc: [rx, ry, xAxisRotation(deg), largeArcFlag, sweepFlag, x, y]
				final double rx = c[0];
				final double ry = c[1];
				final double xAxisRotDeg = c[2];
				final boolean largeArc = c[3] != 0;
				final boolean sweep = c[4] != 0;
				final double endX = c[5] + x;
				final double endY = c[6] + y;
				arcToCubics(curX, curY, rx, ry, xAxisRotDeg, largeArc, sweep, endX, endY);
				ensureVisible(endX, endY);
				curX = endX;
				curY = endY;

			} else if (type == USegmentType.SEG_CLOSE) {
				cb.closePath();
			}
			// Unknown segment types are silently ignored - UPath's enum is
			// closed, so we should never get here in practice.
		}

		paintPath();
	}

	/**
	 * Internal helper: emit a quadratic as a cubic. Same math as the public
	 * {@link #quadto} method, kept private here to avoid disturbing the
	 * path-building state machine (no {@code newpath}/{@code fill} dance).
	 */
	private void quadToInternal(double x0, double y0, double cx, double cy, double x2, double y2) {
		final double c1x = x0 + 2.0 / 3.0 * (cx - x0);
		final double c1y = y0 + 2.0 / 3.0 * (cy - y0);
		final double c2x = x2 + 2.0 / 3.0 * (cx - x2);
		final double c2y = y2 + 2.0 / 3.0 * (cy - y2);
		cb.curveTo((float) c1x, ny(c1y), (float) c2x, ny(c2y), (float) x2, ny(y2));
	}

	// ----------------------------------------------------------------------
	// SVG arc -> cubic Bezier conversion
	// ----------------------------------------------------------------------

	/**
	 * Convert an SVG-style elliptical arc into a sequence of cubic Beziers and emit
	 * them via {@code cb.curveTo}. Follows SVG 1.1 Appendix F.6 (endpoint-to-center
	 * parametrization) and approximates each ~90 degree slice of the sweep with a
	 * single cubic using the standard 4/3*tan(a/4) control-point distance.
	 *
	 * @param x1           start point X (already translated, in caller coords)
	 * @param y1           start point Y
	 * @param rxIn         radius along the X axis of the ellipse (before rotation)
	 * @param ryIn         radius along the Y axis
	 * @param phiDeg       rotation of the ellipse's X axis, in degrees
	 * @param largeArcFlag SVG large-arc flag
	 * @param sweepFlag    SVG sweep flag
	 * @param x2           end point X
	 * @param y2           end point Y
	 */
	private void arcToCubics(double x1, double y1, double rxIn, double ryIn, double phiDeg, boolean largeArcFlag,
			boolean sweepFlag, double x2, double y2) {

		// Degenerate cases per SVG spec F.6.2:
		// - identical endpoints: do nothing
		// - either radius zero: straight line
		if (x1 == x2 && y1 == y2)
			return;
		if (rxIn == 0 || ryIn == 0) {
			cb.lineTo((float) x2, ny(y2));
			return;
		}

		double rx = Math.abs(rxIn);
		double ry = Math.abs(ryIn);
		final double phi = Math.toRadians(phiDeg);
		final double cosPhi = Math.cos(phi);
		final double sinPhi = Math.sin(phi);

		// Step 1: compute (x1', y1') in the rotated coordinate system.
		final double dx = (x1 - x2) / 2.0;
		final double dy = (y1 - y2) / 2.0;
		final double x1p = cosPhi * dx + sinPhi * dy;
		final double y1p = -sinPhi * dx + cosPhi * dy;

		// Step 2: if the radii are too small to reach both endpoints, scale them
		// up uniformly per SVG F.6.6.2.
		final double lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
		if (lambda > 1.0) {
			final double s = Math.sqrt(lambda);
			rx *= s;
			ry *= s;
		}

		// Step 3: center (cx', cy') in the rotated system.
		final double rx2 = rx * rx;
		final double ry2 = ry * ry;
		final double x1p2 = x1p * x1p;
		final double y1p2 = y1p * y1p;

		double numerator = rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2;
		final double denominator = rx2 * y1p2 + ry2 * x1p2;
		if (numerator < 0)
			numerator = 0;
		double coef = Math.sqrt(numerator / denominator);
		if (largeArcFlag == sweepFlag)
			coef = -coef;
		final double cxp = coef * (rx * y1p) / ry;
		final double cyp = coef * -(ry * x1p) / rx;

		// Step 4: center (cx, cy) in the original system.
		final double mx = (x1 + x2) / 2.0;
		final double my = (y1 + y2) / 2.0;
		final double cx = cosPhi * cxp - sinPhi * cyp + mx;
		final double cy = sinPhi * cxp + cosPhi * cyp + my;

		// Step 5: start angle theta1 and sweep deltaTheta.
		final double ux = (x1p - cxp) / rx;
		final double uy = (y1p - cyp) / ry;
		final double vx = (-x1p - cxp) / rx;
		final double vy = (-y1p - cyp) / ry;

		final double theta1 = angleBetween(1, 0, ux, uy);
		double deltaTheta = angleBetween(ux, uy, vx, vy);
		if (!sweepFlag && deltaTheta > 0)
			deltaTheta -= 2 * Math.PI;
		else if (sweepFlag && deltaTheta < 0)
			deltaTheta += 2 * Math.PI;

		// Step 6: split sweep into <= 90 degree slices and approximate each
		// with one cubic Bezier. The standard approximation uses control
		// distance alpha = (4/3) * tan(slice/4).
		final int nSlices = (int) Math.ceil(Math.abs(deltaTheta) / (Math.PI / 2.0));
		final double slice = deltaTheta / nSlices;
		final double alpha = (4.0 / 3.0) * Math.tan(slice / 4.0);

		double t1 = theta1;
		double cos1 = Math.cos(t1);
		double sin1 = Math.sin(t1);
		double curX = x1;
		double curY = y1;

		for (int i = 0; i < nSlices; i++) {
			final double t2 = t1 + slice;
			final double cos2 = Math.cos(t2);
			final double sin2 = Math.sin(t2);

			// Endpoint of this slice on the unit-circle parametrization,
			// then mapped back to the rotated ellipse.
			final double ex = cosPhi * (rx * cos2) - sinPhi * (ry * sin2) + cx;
			final double ey = sinPhi * (rx * cos2) + cosPhi * (ry * sin2) + cy;

			// Control points: tangents at t1 and t2, scaled by alpha.
			final double c1ux = -rx * sin1 * alpha;
			final double c1uy = ry * cos1 * alpha;
			final double c2ux = rx * sin2 * alpha;
			final double c2uy = -ry * cos2 * alpha;

			final double c1x = curX + (cosPhi * c1ux - sinPhi * c1uy);
			final double c1y = curY + (sinPhi * c1ux + cosPhi * c1uy);
			final double c2x = ex + (cosPhi * c2ux - sinPhi * c2uy);
			final double c2y = ey + (sinPhi * c2ux + cosPhi * c2uy);

			cb.curveTo((float) c1x, ny(c1y), (float) c2x, ny(c2y), (float) ex, ny(ey));

			t1 = t2;
			cos1 = cos2;
			sin1 = sin2;
			curX = ex;
			curY = ey;
		}
	}

	/**
	 * Signed angle between two 2D vectors, in radians, in {@code (-pi, pi]}. Used
	 * internally by {@link #arcToCubics}.
	 */
	private static double angleBetween(double ux, double uy, double vx, double vy) {
		final double dot = ux * vx + uy * vy;
		final double lenU = Math.sqrt(ux * ux + uy * uy);
		final double lenV = Math.sqrt(vx * vx + vy * vy);
		double cos = dot / (lenU * lenV);
		if (cos < -1)
			cos = -1;
		else if (cos > 1)
			cos = 1;
		final double sign = (ux * vy - uy * vx) < 0 ? -1 : 1;
		return sign * Math.acos(cos);
	}


}