QuotedPrintableStream.php
PHP
Path: src/Mail/QuotedPrintableStream.php
<?php
namespace mini\Mail;
use Psr\Http\Message\StreamInterface;
/**
* Quoted-Printable Encoding Stream
*
* Wraps a StreamInterface and encodes its content as quoted-printable on-the-fly.
* Produces output with soft line breaks at 76 characters per RFC 2045.
*
* Quoted-printable is preferred for text content with occasional non-ASCII
* characters, as it keeps ASCII text readable while encoding special chars.
*
* Encoding rules (RFC 2045 Section 6.7):
* - Literal representation for printable ASCII (33-60, 62-126) except =
* - =XX hex encoding for non-printable and non-ASCII characters
* - Soft line breaks (=\r\n) at 76 characters
* - Trailing whitespace must be encoded
*
* @internal Used by Email::compile() for encoding text with non-ASCII
*/
class QuotedPrintableStream implements StreamInterface
{
private const MAX_LINE_LENGTH = 76;
private const CRLF = "\r\n";
private StreamInterface $source;
private string $buffer = '';
private int $lineLength = 0;
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 Quoted-printable encoded data
*/
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
{
if ($this->source->eof()) {
$this->sourceExhausted = true;
return;
}
// Read a chunk from source
$raw = $this->source->read(1024);
if ($raw === '') {
$this->sourceExhausted = true;
return;
}
// Process byte by byte
$len = strlen($raw);
for ($i = 0; $i < $len; $i++) {
$byte = $raw[$i];
$ord = ord($byte);
// Handle line endings - preserve CRLF
if ($byte === "\r" && $i + 1 < $len && $raw[$i + 1] === "\n") {
$this->buffer .= self::CRLF;
$this->lineLength = 0;
$i++; // Skip the \n
continue;
}
// Standalone \r or \n - normalize to CRLF
if ($byte === "\r" || $byte === "\n") {
$this->buffer .= self::CRLF;
$this->lineLength = 0;
continue;
}
// Determine encoded representation
$encoded = $this->encodeChar($byte, $ord);
// Check if we need a soft line break
// Reserve space: encoded char length + potential soft break (=\r\n = 3 chars)
if ($this->lineLength + strlen($encoded) > self::MAX_LINE_LENGTH - 1) {
$this->buffer .= '=' . self::CRLF;
$this->lineLength = 0;
}
$this->buffer .= $encoded;
$this->lineLength += strlen($encoded);
}
// If source is now exhausted, handle trailing whitespace
if ($this->source->eof()) {
$this->sourceExhausted = true;
}
}
/**
* Encode a single character
*
* @param string $byte The character
* @param int $ord The ordinal value
* @return string Encoded representation
*/
private function encodeChar(string $byte, int $ord): string
{
// Printable ASCII (33-126) except = (61)
// Tab (9) and space (32) are allowed except at line end (handled separately)
if (($ord >= 33 && $ord <= 60) || ($ord >= 62 && $ord <= 126)) {
return $byte;
}
// Space and tab - encode if at potential line end, otherwise literal
// For simplicity, always encode trailing whitespace would require lookahead
// We'll encode spaces/tabs that could be at line end
if ($ord === 9 || $ord === 32) {
// For streaming, we can't easily know if this is at line end
// Keep literal - trailing whitespace at real line ends will be handled
// by the email transport or we could peek ahead
return $byte;
}
// Everything else gets hex encoded
return sprintf('=%02X', $ord);
}
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->lineLength = 0;
$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('QuotedPrintableStream does not support tell()');
}
public function seek(int $offset, int $whence = SEEK_SET): void
{
throw new \RuntimeException('QuotedPrintableStream 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->lineLength = 0;
$this->sourceExhausted = false;
}
public function isSeekable(): bool
{
return false;
}
public function isWritable(): bool
{
return false;
}
public function write(string $string): int
{
throw new \RuntimeException('QuotedPrintableStream 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 = '';
}
public function detach()
{
$this->detached = true;
$this->buffer = '';
return null;
}
}