Base64Stream.php
PHP
Path: src/Mail/Base64Stream.php
<?php
namespace mini\Mail;
use Psr\Http\Message\StreamInterface;
/**
* Base64 Encoding Stream
*
* Wraps a StreamInterface and encodes its content as base64 on-the-fly.
* Produces output with line breaks every 76 characters per RFC 2045.
*
* This is a read-only, forward-only stream that encodes data as it's read.
*
* @internal Used by Email::compile() for encoding binary attachments
*/
class Base64Stream implements StreamInterface
{
private const LINE_LENGTH = 76;
private const CRLF = "\r\n";
private StreamInterface $source;
private string $buffer = '';
private string $remainder = '';
private bool $sourceExhausted = false;
private bool $detached = false;
/**
* @param StreamInterface $source The source stream to encode
*/
public function __construct(StreamInterface $source)
{
$this->source = $source;
if ($source->isSeekable()) {
$source->rewind();
}
}
/**
* Read encoded data from the stream
*
* @param int $length Maximum bytes to read
* @return string Base64-encoded data with line breaks
*/
public function read(int $length): string
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
// Fill buffer if needed
while (strlen($this->buffer) < $length && !$this->sourceExhausted) {
$this->fillBuffer();
}
// Return requested amount
$result = substr($this->buffer, 0, $length);
$this->buffer = substr($this->buffer, strlen($result));
return $result;
}
/**
* Fill the internal buffer with more encoded data
*/
private function fillBuffer(): void
{
// Read raw bytes from source
// We need multiples of 3 bytes for clean base64 encoding (3 bytes -> 4 chars)
// Read enough to produce several lines
$chunkSize = 57 * 10; // 57 bytes = 76 chars after base64, read 10 lines worth
$raw = $this->remainder;
if (!$this->source->eof()) {
$raw .= $this->source->read($chunkSize);
}
if ($raw === '' && $this->source->eof()) {
$this->sourceExhausted = true;
return;
}
// Keep remainder that doesn't divide evenly by 3
$usableLength = (int) (floor(strlen($raw) / 3) * 3);
if ($this->source->eof()) {
// At end, encode everything including padding
$usableLength = strlen($raw);
$this->sourceExhausted = true;
$this->remainder = '';
} else {
$this->remainder = substr($raw, $usableLength);
$raw = substr($raw, 0, $usableLength);
}
if ($raw === '') {
return;
}
// Encode and add line breaks
$encoded = base64_encode($raw);
$lines = str_split($encoded, self::LINE_LENGTH);
$this->buffer .= implode(self::CRLF, $lines);
// Add final CRLF if not at end, or if we have output
if (!$this->sourceExhausted || $encoded !== '') {
$this->buffer .= self::CRLF;
}
}
public function getContents(): string
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
$contents = '';
while (!$this->eof()) {
$contents .= $this->read(8192);
}
return $contents;
}
public function __toString(): string
{
try {
if ($this->source->isSeekable()) {
$this->source->rewind();
$this->buffer = '';
$this->remainder = '';
$this->sourceExhausted = false;
}
return $this->getContents();
} catch (\Throwable $e) {
return '';
}
}
public function eof(): bool
{
return $this->detached || ($this->sourceExhausted && $this->buffer === '');
}
public function tell(): int
{
throw new \RuntimeException('Base64Stream does not support tell()');
}
public function seek(int $offset, int $whence = SEEK_SET): void
{
throw new \RuntimeException('Base64Stream is not seekable');
}
public function rewind(): void
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
if (!$this->source->isSeekable()) {
throw new \RuntimeException('Cannot rewind: source stream is not seekable');
}
$this->source->rewind();
$this->buffer = '';
$this->remainder = '';
$this->sourceExhausted = false;
}
public function isSeekable(): bool
{
return false;
}
public function isWritable(): bool
{
return false;
}
public function write(string $string): int
{
throw new \RuntimeException('Base64Stream is not writable');
}
public function isReadable(): bool
{
return !$this->detached;
}
public function getSize(): ?int
{
// Size is unpredictable due to encoding expansion and line breaks
return null;
}
public function getMetadata(?string $key = null): mixed
{
$meta = [
'seekable' => false,
'eof' => $this->eof(),
];
if ($key === null) {
return $meta;
}
return $meta[$key] ?? null;
}
public function close(): void
{
$this->detached = true;
$this->buffer = '';
$this->remainder = '';
}
public function detach()
{
$this->detached = true;
$this->buffer = '';
$this->remainder = '';
return null;
}
}