<?php
/**
 * BitForgeNet Guest Tickets - Minimal text PDF writer.
 *
 * The plugin already ships a PDF writer, but it only knows how to place a JPEG
 * on a page — right for a ticket, wrong for a list. Printing a roll-call as an
 * image would mean a blurry, three-megabyte page that no one can search. So this
 * writes real text instead, using the two fonts every PDF reader is required to
 * have built in. Nothing is embedded, nothing is compressed, the file stays a few
 * kilobytes, and the result is crisp at any zoom.
 *
 * Coordinates are given in millimetres from the top-left corner, because that is
 * how a page is actually laid out; the conversion to PostScript points measured
 * from the bottom happens here.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.3.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Builds a paginated text document.
 */
class BFNET_Sheet {

	const PT_PER_MM = 2.834645669;

	const FONT_REGULAR = 'F1';
	const FONT_BOLD    = 'F2';

	/**
	 * Page width in millimetres.
	 *
	 * @var float
	 */
	private $width;

	/**
	 * Page height in millimetres.
	 *
	 * @var float
	 */
	private $height;

	/**
	 * Finished page content streams.
	 *
	 * @var array
	 */
	private $pages = array();

	/**
	 * Operators queued for the page being built.
	 *
	 * @var array
	 */
	private $buffer = array();

	/**
	 * Document metadata.
	 *
	 * @var array
	 */
	private $meta = array(
		'title'  => 'BitForgeNet Guest Tickets',
		'author' => 'BitForgeNet Guest Tickets',
	);

	/**
	 * Widths of Helvetica glyphs, in thousandths of the font size.
	 *
	 * Enough of the table to measure any Latin string; anything not listed falls
	 * back to the width of a lowercase n, which is close enough to keep a column
	 * from overflowing.
	 *
	 * @var array
	 */
	private static $widths = array(
		' ' => 278, '!' => 278, '"' => 355, '#' => 556, '$' => 556, '%' => 889, '&' => 667,
		"'" => 191, '(' => 333, ')' => 333, '*' => 389, '+' => 584, ',' => 278, '-' => 333,
		'.' => 278, '/' => 278, '0' => 556, '1' => 556, '2' => 556, '3' => 556, '4' => 556,
		'5' => 556, '6' => 556, '7' => 556, '8' => 556, '9' => 556, ':' => 278, ';' => 278,
		'<' => 584, '=' => 584, '>' => 584, '?' => 556, '@' => 1015, 'A' => 667, 'B' => 667,
		'C' => 722, 'D' => 722, 'E' => 667, 'F' => 611, 'G' => 778, 'H' => 722, 'I' => 278,
		'J' => 500, 'K' => 667, 'L' => 556, 'M' => 833, 'N' => 722, 'O' => 778, 'P' => 667,
		'Q' => 778, 'R' => 722, 'S' => 667, 'T' => 611, 'U' => 722, 'V' => 667, 'W' => 944,
		'X' => 667, 'Y' => 667, 'Z' => 611, '[' => 278, '\\' => 278, ']' => 278, '^' => 469,
		'_' => 556, '`' => 333, 'a' => 556, 'b' => 556, 'c' => 500, 'd' => 556, 'e' => 556,
		'f' => 278, 'g' => 556, 'h' => 556, 'i' => 222, 'j' => 222, 'k' => 500, 'l' => 222,
		'm' => 833, 'n' => 556, 'o' => 556, 'p' => 556, 'q' => 556, 'r' => 333, 's' => 500,
		't' => 278, 'u' => 556, 'v' => 500, 'w' => 722, 'x' => 500, 'y' => 500, 'z' => 500,
		'{' => 334, '|' => 260, '}' => 334, '~' => 584,
	);

	/**
	 * Start a document.
	 *
	 * @param float $width_mm  Page width.
	 * @param float $height_mm Page height.
	 */
	public function __construct( $width_mm = 210, $height_mm = 297 ) {
		$this->width  = (float) $width_mm;
		$this->height = (float) $height_mm;
	}

	/**
	 * Set the title and author.
	 *
	 * @param array $meta Metadata.
	 * @return void
	 */
	public function set_meta( array $meta ) {
		$this->meta = array_merge( $this->meta, array_map( 'strval', $meta ) );
	}

	/**
	 * Page dimensions in millimetres.
	 *
	 * @return array
	 */
	public function size() {
		return array( 'width' => $this->width, 'height' => $this->height );
	}

	/**
	 * Close the current page and begin a new one.
	 *
	 * @return void
	 */
	public function new_page() {
		if ( $this->buffer ) {
			$this->pages[] = implode( "\n", $this->buffer );
			$this->buffer  = array();
		}
	}

	/**
	 * How many pages have been closed so far.
	 *
	 * @return int
	 */
	public function page_count() {
		return count( $this->pages ) + ( $this->buffer ? 1 : 0 );
	}

	/* --------------------------------------------------------------------- */
	/* Drawing                                                               */
	/* --------------------------------------------------------------------- */

	/**
	 * Convert a top-down millimetre coordinate to a bottom-up point.
	 *
	 * @param float $mm Millimetres from the top.
	 * @return float
	 */
	private function y( $mm ) {
		return round( ( $this->height - $mm ) * self::PT_PER_MM, 3 );
	}

	/**
	 * Convert a millimetre coordinate to points.
	 *
	 * @param float $mm Millimetres.
	 * @return float
	 */
	private function x( $mm ) {
		return round( $mm * self::PT_PER_MM, 3 );
	}

	/**
	 * Draw a filled rectangle.
	 *
	 * @param float $x      Left edge, mm.
	 * @param float $y      Top edge, mm.
	 * @param float $w      Width, mm.
	 * @param float $h      Height, mm.
	 * @param array $rgb    Fill colour, 0-255.
	 * @return void
	 */
	public function rect( $x, $y, $w, $h, $rgb ) {
		$this->buffer[] = sprintf(
			'q %s %s %s rg %s %s %s %s re f Q',
			$this->unit( $rgb[0] ),
			$this->unit( $rgb[1] ),
			$this->unit( $rgb[2] ),
			$this->x( $x ),
			$this->y( $y + $h ),
			$this->x( $w ),
			round( $h * self::PT_PER_MM, 3 )
		);
	}

	/**
	 * Draw a horizontal rule.
	 *
	 * @param float $x     Left edge, mm.
	 * @param float $y     Position, mm from the top.
	 * @param float $w     Length, mm.
	 * @param array $rgb   Colour, 0-255.
	 * @param float $thick Thickness, mm.
	 * @return void
	 */
	public function rule( $x, $y, $w, $rgb, $thick = 0.2 ) {
		$this->rect( $x, $y, $w, $thick, $rgb );
	}

	/**
	 * Draw an unfilled square, for ticking with a pen.
	 *
	 * @param float $x    Left edge, mm.
	 * @param float $y    Top edge, mm.
	 * @param float $size Side length, mm.
	 * @param array $rgb  Line colour.
	 * @return void
	 */
	public function box( $x, $y, $size, $rgb = array( 40, 40, 40 ) ) {
		$this->buffer[] = sprintf(
			'q %s %s %s RG 0.9 w %s %s %s %s re S Q',
			$this->unit( $rgb[0] ),
			$this->unit( $rgb[1] ),
			$this->unit( $rgb[2] ),
			$this->x( $x ),
			$this->y( $y + $size ),
			$this->x( $size ),
			round( $size * self::PT_PER_MM, 3 )
		);
	}

	/**
	 * Place a line of text.
	 *
	 * @param float  $x     Left edge, mm.
	 * @param float  $y     Baseline, mm from the top.
	 * @param string $value Text.
	 * @param float  $size  Font size in points.
	 * @param bool   $bold  Use the bold face.
	 * @param array  $rgb   Colour, 0-255.
	 * @param float  $track Letter spacing in points.
	 * @return void
	 */
	public function text( $x, $y, $value, $size = 10, $bold = false, $rgb = array( 20, 20, 20 ), $track = 0 ) {
		$this->buffer[] = sprintf(
			'BT /%s %s Tf %s Tc %s %s %s rg %s %s Td (%s) Tj ET',
			$bold ? self::FONT_BOLD : self::FONT_REGULAR,
			round( $size, 2 ),
			round( $track, 2 ),
			$this->unit( $rgb[0] ),
			$this->unit( $rgb[1] ),
			$this->unit( $rgb[2] ),
			$this->x( $x ),
			$this->y( $y ),
			$this->escape( $value )
		);
	}

	/**
	 * Place text that must not run past a given width.
	 *
	 * @param float  $x     Left edge, mm.
	 * @param float  $y     Baseline, mm.
	 * @param string $value Text.
	 * @param float  $max   Maximum width, mm.
	 * @param float  $size  Font size.
	 * @param bool   $bold  Bold face.
	 * @param array  $rgb   Colour.
	 * @return void
	 */
	public function text_clipped( $x, $y, $value, $max, $size = 10, $bold = false, $rgb = array( 20, 20, 20 ) ) {
		$value = $this->fit( $value, $max, $size );
		$this->text( $x, $y, $value, $size, $bold, $rgb );
	}

	/**
	 * Width of a string, in millimetres.
	 *
	 * @param string $value Text.
	 * @param float  $size  Font size in points.
	 * @return float
	 */
	public function width_of( $value, $size ) {
		$value = $this->latin( $value );
		$total = 0;
		$len   = strlen( $value );

		for ( $i = 0; $i < $len; $i++ ) {
			$char   = $value[ $i ];
			$total += isset( self::$widths[ $char ] ) ? self::$widths[ $char ] : 556;
		}

		return ( $total / 1000 ) * $size / self::PT_PER_MM;
	}

	/**
	 * Shorten a string until it fits, adding an ellipsis.
	 *
	 * @param string $value Text.
	 * @param float  $max   Width in millimetres.
	 * @param float  $size  Font size.
	 * @return string
	 */
	public function fit( $value, $max, $size ) {
		$value = $this->latin( $value );
		if ( $this->width_of( $value, $size ) <= $max ) {
			return $value;
		}

		while ( strlen( $value ) > 1 && $this->width_of( $value . '...', $size ) > $max ) {
			$value = substr( $value, 0, -1 );
		}

		return rtrim( $value ) . '...';
	}

	/* --------------------------------------------------------------------- */
	/* Output                                                                */
	/* --------------------------------------------------------------------- */

	/**
	 * Normalise a colour channel to the 0-1 PDF range.
	 *
	 * @param int $value 0-255.
	 * @return string
	 */
	private function unit( $value ) {
		return (string) round( max( 0, min( 255, (int) $value ) ) / 255, 4 );
	}

	/**
	 * Reduce a string to what the built-in fonts can actually draw.
	 *
	 * Guests are called Émilie and Muñoz; Helvetica with WinAnsi handles those.
	 * Anything further out — Arabic, Chinese — has no glyph at all, so it is
	 * transliterated rather than printed as blank boxes.
	 *
	 * @param string $value Text.
	 * @return string
	 */
	private function latin( $value ) {
		$value = (string) $value;

		if ( function_exists( 'iconv' ) ) {
			$converted = @iconv( 'UTF-8', 'Windows-1252//TRANSLIT', $value ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
			if ( false !== $converted ) {
				return $converted;
			}
		}

		return remove_accents( $value );
	}

	/**
	 * Escape a string for a PDF literal.
	 *
	 * @param string $value Text.
	 * @return string
	 */
	private function escape( $value ) {
		return strtr( $this->latin( $value ), array( '\\' => '\\\\', '(' => '\\(', ')' => '\\)', "\r" => '', "\n" => ' ' ) );
	}

	/**
	 * Assemble the file.
	 *
	 * @return string
	 * @throws RuntimeException When there is nothing to write.
	 */
	public function render() {
		$this->new_page();

		if ( ! $this->pages ) {
			throw new RuntimeException( 'Cannot render a sheet with no pages.' );
		}

		$count = count( $this->pages );

		// 1 catalog, 2 page tree, 3 info, 4 regular font, 5 bold font,
		// then per page i: 6 + i*2 = page, 7 + i*2 = content.
		$kids = array();
		for ( $i = 0; $i < $count; $i++ ) {
			$kids[] = ( 6 + ( $i * 2 ) ) . ' 0 R';
		}

		$objects    = array();
		$objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
		$objects[2] = '<< /Type /Pages /Kids [' . implode( ' ', $kids ) . '] /Count ' . $count . ' >>';
		$objects[3] = '<< /Title (' . $this->escape( $this->meta['title'] ) . ') /Author (' . $this->escape( $this->meta['author'] )
			. ') /Producer (BitForgeNet Guest Tickets for WordPress) /CreationDate (D:' . gmdate( 'YmdHis' ) . "Z) >>";
		$objects[4] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>';
		$objects[5] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>';

		$media = '[0 0 ' . round( $this->width * self::PT_PER_MM, 3 ) . ' ' . round( $this->height * self::PT_PER_MM, 3 ) . ']';

		foreach ( $this->pages as $i => $stream ) {
			$page    = 6 + ( $i * 2 );
			$content = $page + 1;

			$objects[ $page ] = '<< /Type /Page /Parent 2 0 R /MediaBox ' . $media
				. ' /Resources << /Font << /' . self::FONT_REGULAR . ' 4 0 R /' . self::FONT_BOLD . ' 5 0 R >> >>'
				. ' /Contents ' . $content . ' 0 R >>';

			$objects[ $content ] = '<< /Length ' . strlen( $stream ) . " >>\nstream\n" . $stream . "\nendstream";
		}

		ksort( $objects );

		$pdf     = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
		$offsets = array();

		foreach ( $objects as $number => $body ) {
			$offsets[ $number ] = strlen( $pdf );
			$pdf               .= $number . " 0 obj\n" . $body . "\nendobj\n";
		}

		$start = strlen( $pdf );
		$max   = max( array_keys( $objects ) ) + 1;

		$pdf .= "xref\n0 " . $max . "\n0000000000 65535 f \n";
		for ( $n = 1; $n < $max; $n++ ) {
			$pdf .= sprintf( "%010d 00000 n \n", isset( $offsets[ $n ] ) ? $offsets[ $n ] : 0 );
		}

		$pdf .= "trailer\n<< /Size " . $max . " /Root 1 0 R /Info 3 0 R >>\nstartxref\n" . $start . "\n%%EOF";

		return $pdf;
	}
}
