Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/upstream-prs/01-asciihex-decode/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,12 @@ A nicer design would be a `FilterRegistry` with one class per filter. We don't i
| Maintainer rejects the spot-add approach, prefers a registry abstraction up-front | Low | Discuss in the issue (the "ask" section) before opening the PR |
Comment thread
WilcoLouwerse marked this conversation as resolved.
Comment thread
WilcoLouwerse marked this conversation as resolved.
| Edge-case input not caught by `@hex2bin` (e.g. valid hex but unexpected length pattern) | Low | Unit test odd-length, whitespace-rich, and invalid-character inputs |
| Round-trip diff vs. original byte layout (whitespace stripped, EOD position shifted) | Acceptable | PDF readers parse the same logical content; byte-identical round-trip is not promised by the spec |


---

---

## Implementation note

See `openspec/changes/feat-asciihex-decode/design.md` (the canonical artefact) for the shipped-implementation note. This file keeps the original proposal/design/tasks content for the eventual upstream submission to dealfonso/sapp; implementation specifics are tracked in the OpenSpec change to avoid duplicate-source drift.
9 changes: 9 additions & 0 deletions docs/upstream-prs/01-asciihex-decode/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ This feature is the first in a series adding decoder coverage for the text-relev
- **Downstream consumers:** PDF objects using `/Filter /ASCIIHexDecode` become readable / writable. Nothing previously working regresses.
- **Spec target:** PDF 1.7 §7.4.2 (ASCIIHexDecode Filter).
- **Out of scope:** filter chaining (`/Filter [/X /Y]` array form, separate PR), other filters in the series (separate PRs).


---

---

## Implementation note

See `openspec/changes/feat-asciihex-decode/design.md` (the canonical artefact) for the shipped-implementation note. This file keeps the original proposal/design/tasks content for the eventual upstream submission to dealfonso/sapp; implementation specifics are tracked in the OpenSpec change to avoid duplicate-source drift.
9 changes: 9 additions & 0 deletions docs/upstream-prs/01-asciihex-decode/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,12 @@
- [ ] 5.1 No regressions in the existing FlateDecode path — verify against `examples/testdoc.pdf` (the existing example PDF, which is FlateDecoded).
- [ ] 5.2 No new dependencies — pure PHP only, no Composer additions.
- [ ] 5.3 Spec-validity: every Requirement in `spec.md` has a passing verification step.


---

---

## Implementation note

See `openspec/changes/feat-asciihex-decode/design.md` (the canonical artefact) for the shipped-implementation note. This file keeps the original proposal/design/tasks content for the eventual upstream submission to dealfonso/sapp; implementation specifics are tracked in the OpenSpec change to avoid duplicate-source drift.
280 changes: 280 additions & 0 deletions examples/poc-filter-roundtrip-asciihex.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
<?php
/**
* ASCIIHexDecode round-trip verification gate.
*
* Anchors the OpenSpec contract for `feat-asciihex-decode`:
*
* REQ-1 — ASCIIHexDecode SHALL decode per PDF 1.7 §7.4.2
* REQ-2 — ASCIIHexEncode SHALL emit uppercase hex pairs with EOD
* REQ-3 — ASCIIHexDecode round-trip MUST be lossless
* REQ-4 — ASCIIHexDecode SHALL fail safely on illegal characters
* REQ-5 — Chain dispatcher MUST recognise /ASCIIHexDecode on decode + encode
*
* Exit code 0 on success; 1 on any assertion failure.
*
* SPDX-License-Identifier: LGPL-3.0-or-later
*/

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use ddn\sapp\PDFObject;
use ddn\sapp\pdfvalue\PDFValueObject;
use ddn\sapp\pdfvalue\PDFValueList;
use ddn\sapp\pdfvalue\PDFValueType;

$failures = [];

function invokeProtectedStatic(string $method, array $args) {
Comment thread
WilcoLouwerse marked this conversation as resolved.
$ref = new ReflectionClass(PDFObject::class);
$m = $ref->getMethod($method);
$m->setAccessible(true);
return $m->invokeArgs(null, $args);
}

function buildObj($filterValue, string $rawStream = ''): PDFObject {
$value = new PDFValueObject();
if ($filterValue !== null) {
$value['Filter'] = $filterValue;
}
$obj = new PDFObject(1, $value);
$obj->set_stream($rawStream, true);
return $obj;
}

/* ---------------------------------------------------------------- *
* REQ-1 — decode scenarios
* ---------------------------------------------------------------- */
{
$decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656C6C6F>', null]);
if ($decoded !== 'Hello') {
$failures[] = "REQ-1 (even-length): expected 'Hello', got " . bin2hex((string) $decoded);
}

// Odd-length: 41B> → \x41\xB0 (trailing B paired with implicit 0)
$decoded = invokeProtectedStatic('ASCIIHexDecode', ['41B>', null]);
if ($decoded !== "\x41\xB0") {
$failures[] = "REQ-1 (odd-length pad): expected 0x41 0xB0, got " . bin2hex((string) $decoded);
}

// Whitespace anywhere
$decoded = invokeProtectedStatic('ASCIIHexDecode', ["48 65\n6C\t6C\r\n6F>", null]);
if ($decoded !== 'Hello') {
$failures[] = "REQ-1 (whitespace): expected 'Hello', got " . bin2hex((string) $decoded);
}

// Lowercase hex
$decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656c6c6f>', null]);
if ($decoded !== 'Hello') {
$failures[] = "REQ-1 (lowercase): expected 'Hello', got " . bin2hex((string) $decoded);
}

// Trailing bytes after EOD ignored
$decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656C6C6F>garbage', null]);
if ($decoded !== 'Hello') {
$failures[] = "REQ-1 (trailing after EOD): expected 'Hello', got " . bin2hex((string) $decoded);
}
}

/* ---------------------------------------------------------------- *
* REQ-2 — encode scenarios
* ---------------------------------------------------------------- */
{
// Basic encode
$encoded = invokeProtectedStatic('ASCIIHexEncode', ['Hello', null]);
if ($encoded !== '48656C6C6F>') {
$failures[] = "REQ-2 (basic): expected '48656C6C6F>', got '$encoded'";
}

// Empty input → just EOD
$encoded = invokeProtectedStatic('ASCIIHexEncode', ['', null]);
if ($encoded !== '>') {
$failures[] = "REQ-2 (empty): expected '>', got '$encoded'";
}

// Line wrap at 80 columns — input of 50 bytes encodes to 100 hex chars,
// must produce at least one '\n' and no line > 80 chars
$longInput = str_repeat('A', 50);
$encoded = invokeProtectedStatic('ASCIIHexEncode', [$longInput, null]);
if (strpos($encoded, "\n") === false) {
$failures[] = "REQ-2 (line wrap): 50-byte input did not produce any newlines";
}
foreach (explode("\n", $encoded) as $line) {
if (strlen($line) > 80) {
$failures[] = "REQ-2 (line wrap): line longer than 80 chars (" . strlen($line) . ")";
break;
}
}
}

/* ---------------------------------------------------------------- *
* REQ-3 — lossless round-trip on 1024-byte random binary
* ---------------------------------------------------------------- */
{
$original = random_bytes(1024);
$encoded = invokeProtectedStatic('ASCIIHexEncode', [$original, null]);
$decoded = invokeProtectedStatic('ASCIIHexDecode', [$encoded, null]);
if ($decoded !== $original) {
$failures[] = "REQ-3 (binary round-trip): 1024-byte input did not survive encode→decode (got " . strlen((string) $decoded) . " bytes back)";
}
}

/* ---------------------------------------------------------------- *
* REQ-4 — illegal character fail-safe (returns false per dispatcher contract)
* ---------------------------------------------------------------- */
{
$original = '48656C!6C6F>'; // contains '!'
ob_start();
$decoded = invokeProtectedStatic('ASCIIHexDecode', [$original, null]);
ob_end_clean();
if ($decoded !== false) {
$failures[] = "REQ-4 (illegal char): expected false on failure, got " . var_export($decoded, true);
}
}

/* ---------------------------------------------------------------- *
* REQ-4 — chain-failure propagation: when an outer ASCIIHexDecode
* layer is malformed, the chain dispatcher MUST short-circuit and
* return false rather than feeding garbage to the next filter.
* ---------------------------------------------------------------- */
{
$filterList = new PDFValueList();
$filterList->push(new PDFValueType('ASCIIHexDecode'));
$filterList->push(new PDFValueType('FlateDecode'));

// Raw stream is malformed hex (contains '!') — outer ASCIIHex
// arm MUST fail before the inner FlateDecode arm ever runs.
$obj = buildObj($filterList, '48656C!6C6F>somegarbage');

ob_start();
$decoded = $obj->get_stream(false);
ob_end_clean();

if ($decoded !== false) {
$failures[] = "REQ-4 (chain failure propagation): get_stream did not return false on outer-layer ASCIIHex failure (got " . var_export($decoded, true) . ")";
}
}

/* ---------------------------------------------------------------- *
* Missing-EOD tolerance (Adobe-compatible documented behaviour)
* ---------------------------------------------------------------- */
{
// Input without trailing `>` — decoder should treat the whole
// input as the data region.
$decoded = invokeProtectedStatic('ASCIIHexDecode', ['48656C6C6F', null]);
if ($decoded !== 'Hello') {
$failures[] = "Missing-EOD tolerance: expected 'Hello', got " . bin2hex((string) $decoded);
}
}

/* ---------------------------------------------------------------- *
* EOD-first / empty edge cases (REQ-1 boundary coverage)
* ---------------------------------------------------------------- */
{
$decoded = invokeProtectedStatic('ASCIIHexDecode', ['>', null]);
if ($decoded !== '') {
$failures[] = "EOD-first empty: expected '', got " . bin2hex((string) $decoded);
}
$decoded = invokeProtectedStatic('ASCIIHexDecode', [" \n \t >", null]);
if ($decoded !== '') {
$failures[] = "Whitespace+EOD empty: expected '', got " . bin2hex((string) $decoded);
}
}

/* ---------------------------------------------------------------- *
* REQ-2/3 — empty input round-trip + 40-byte 80-col boundary
* ---------------------------------------------------------------- */
{
// Round-trip of empty input (decode(encode('')) === '').
$encoded = invokeProtectedStatic('ASCIIHexEncode', ['', null]);
$decoded = invokeProtectedStatic('ASCIIHexDecode', [$encoded, null]);
if ($decoded !== '') {
$failures[] = "REQ-2 (empty round-trip): expected '' got " . bin2hex((string) $decoded);
}

// 40-byte input = 80 hex chars — encodes to a single line of
// exactly 80 chars. The encoder MUST insert a newline before `>`
// so no line exceeds 80 chars (or at least clearly handle the
// boundary; the encoder ships with a `strlen % 80 === 0` guard).
$payload = str_repeat('A', 40);
$encoded = invokeProtectedStatic('ASCIIHexEncode', [$payload, null]);
foreach (explode("\n", $encoded) as $line) {
if (strlen($line) > 80) {
$failures[] = "REQ-2 (40-byte 80-col edge): line longer than 80 chars (" . strlen($line) . ")";
break;
}
}
// Round-trip MUST still produce the original.
$decoded = invokeProtectedStatic('ASCIIHexDecode', [$encoded, null]);
if ($decoded !== $payload) {
$failures[] = "REQ-2 (40-byte round-trip): round-trip mismatch on 80-col boundary";
}
}

/* ---------------------------------------------------------------- *
* REQ-5 — chain dispatcher recognises /ASCIIHexDecode
* ---------------------------------------------------------------- */
{
// Single-filter chain via array form: [/ASCIIHexDecode]
$filterList = new PDFValueList();
$filterList->push(new PDFValueType('ASCIIHexDecode'));

$obj = buildObj($filterList);
$plaintext = 'plain-text payload — ASCIIHex only';
$obj->set_stream($plaintext, false);

$encoded = $obj->get_stream(true);
if ($encoded === $plaintext) {
$failures[] = "REQ-5 (chain-only-ASCIIHex set_stream): did not encode (stored verbatim)";
}
if (substr($encoded, -1) !== '>') {
$failures[] = "REQ-5 (chain-only-ASCIIHex set_stream): encoded stream missing trailing EOD '>'";
}

$decoded = $obj->get_stream(false);
if ($decoded !== $plaintext) {
$failures[] = "REQ-5 (chain-only-ASCIIHex round-trip): expected plaintext, got " . bin2hex((string) $decoded);
}
}

{
// Two-filter chain: [/ASCIIHexDecode /FlateDecode]
// Encode order is REVERSE: FlateDecode first (innermost), then
// ASCIIHexDecode (outermost). Decode order is FORWARD: ASCIIHex
// first to strip the hex envelope, then Flate to decompress.
$filterList = new PDFValueList();
Comment thread
WilcoLouwerse marked this conversation as resolved.
Comment thread
WilcoLouwerse marked this conversation as resolved.
$filterList->push(new PDFValueType('ASCIIHexDecode'));
$filterList->push(new PDFValueType('FlateDecode'));

$obj = buildObj($filterList);
$plaintext = "BT (Chained ASCIIHex outer + Flate inner) Tj ET";
$obj->set_stream($plaintext, false);

$encoded = $obj->get_stream(true);
if (substr($encoded, -1) !== '>') {
$failures[] = "REQ-5 (chain ASCIIHex+Flate set_stream): encoded stream missing trailing '>' (outer ASCIIHex)";
}

$decoded = $obj->get_stream(false);
if ($decoded !== $plaintext) {
$failures[] = "REQ-5 (chain ASCIIHex+Flate round-trip): expected plaintext, got " . bin2hex((string) $decoded);
}
}
Comment thread
WilcoLouwerse marked this conversation as resolved.

/* ---------------------------------------------------------------- *
* Report
* ---------------------------------------------------------------- */
echo "PoC ASCIIHexDecode — round-trip verification\n";
echo " spec: openspec/changes/feat-asciihex-decode/specs/asciihex-decode-filter/spec.md\n\n";

if (count($failures) === 0) {
echo "RESULT: VERIFIED — all 5 REQs covered, no assertion failures.\n";
exit(0);
}

echo "RESULT: FAIL — " . count($failures) . " assertion(s) violated:\n";
foreach ($failures as $msg) {
echo " - $msg\n";
}
exit(1);
Comment thread
WilcoLouwerse marked this conversation as resolved.
Comment thread
WilcoLouwerse marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,22 @@ For any input byte string `$P`, `ASCIIHexDecode(ASCIIHexEncode($P, null), null)`

### REQ-004: ASCIIHexDecode SHALL fail safely on illegal characters

When the decoder encounters a character outside the alphabet plus whitespace plus EOD, it MUST call `p_error()` with a message identifying the failure and MUST return the input bytes unchanged.
When the decoder encounters a character outside the alphabet plus whitespace plus EOD, it MUST call `p_error()` with a message identifying the failure and MUST return `false` (matching upstream `p_error`'s default return + the chain dispatcher's `=== false` short-circuit contract). It MUST NOT return the raw input bytes — that would let downstream filters in a chain see corrupted bytes.

#### Scenario: Illegal character fails safely

- GIVEN the encoded stream is `48656C!6C6F>` (contains `!`)
- WHEN `ASCIIHexDecode` is invoked
- THEN the decoder MUST call `p_error()` with a message naming the illegal character
- AND the return value MUST equal the original encoded stream (unchanged)
- AND the return value MUST be `false` (so the chain dispatcher's `if ($decoded === false) return false;` arm fires)
- AND no exception MUST be thrown

#### Scenario: Chain-failure propagation on outer-ASCIIHex layer

- GIVEN an object with `/Filter [/ASCIIHexDecode /FlateDecode]` and a raw `_stream` whose ASCIIHex layer contains an illegal character
- WHEN `get_stream(false)` is invoked
- THEN the chain dispatcher MUST short-circuit at the ASCIIHex arm and return `false`; the inner `FlateDecode` arm MUST NOT see the malformed bytes

## MODIFIED Requirements

### REQ-001: Array-form `/Filter` SHALL decode in forward chain order
Expand Down
Loading