diff --git a/docs/upstream-prs/01-asciihex-decode/design.md b/docs/upstream-prs/01-asciihex-decode/design.md index d1e8a7c..9020c9d 100644 --- a/docs/upstream-prs/01-asciihex-decode/design.md +++ b/docs/upstream-prs/01-asciihex-decode/design.md @@ -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 | | 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. diff --git a/docs/upstream-prs/01-asciihex-decode/proposal.md b/docs/upstream-prs/01-asciihex-decode/proposal.md index 851db23..a796ab0 100644 --- a/docs/upstream-prs/01-asciihex-decode/proposal.md +++ b/docs/upstream-prs/01-asciihex-decode/proposal.md @@ -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. diff --git a/docs/upstream-prs/01-asciihex-decode/tasks.md b/docs/upstream-prs/01-asciihex-decode/tasks.md index 1a80fac..1f53b2f 100644 --- a/docs/upstream-prs/01-asciihex-decode/tasks.md +++ b/docs/upstream-prs/01-asciihex-decode/tasks.md @@ -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. diff --git a/examples/poc-filter-roundtrip-asciihex.php b/examples/poc-filter-roundtrip-asciihex.php new file mode 100644 index 0000000..25a131a --- /dev/null +++ b/examples/poc-filter-roundtrip-asciihex.php @@ -0,0 +1,280 @@ +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(); + $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); + } +} + +/* ---------------------------------------------------------------- * + * 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); diff --git a/openspec/changes/feat-asciihex-decode/specs/asciihex-decode-filter/spec.md b/openspec/changes/feat-asciihex-decode/specs/asciihex-decode-filter/spec.md index d26afc8..dddaa87 100644 --- a/openspec/changes/feat-asciihex-decode/specs/asciihex-decode-filter/spec.md +++ b/openspec/changes/feat-asciihex-decode/specs/asciihex-decode-filter/spec.md @@ -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 diff --git a/openspec/changes/feat-asciihex-decode/tasks.md b/openspec/changes/feat-asciihex-decode/tasks.md index 70582f7..e0d9d01 100644 --- a/openspec/changes/feat-asciihex-decode/tasks.md +++ b/openspec/changes/feat-asciihex-decode/tasks.md @@ -1,45 +1,45 @@ ## 1. Pre-flight -- [ ] 1.1 Confirm `feat-filter-chain-dispatch` (upstream-PR #05) is merged into `work/text-replacement` before starting — it's a hard dependency -- [ ] 1.2 Branch off `work/text-replacement` as `feat/asciihex-decode` -- [ ] 1.3 Re-read PDF 1.7 §7.4.2 and confirm decoder rules (whitespace + odd-length + EOD + alphabet) +- [x] 1.1 Confirm `feat-filter-chain-dispatch` (upstream-PR #05; fork PR #3) is merged into the integration line OR available as the stack-parent branch +- [x] 1.2 Branch off `feat/filter-chain-dispatch` as `feat/asciihex-decode` (stacked PR; base is the dispatcher branch, NOT `work/text-replacement` directly — once #05 merges upstream, this PR's base auto-retargets) +- [x] 1.3 Re-read PDF 1.7 §7.4.2 and confirm decoder rules (whitespace + odd-length + EOD + alphabet) ## 2. Decode helper -- [ ] 2.1 Add `protected static function ASCIIHexDecode($_stream, $params)` in `src/PDFObject.php`, modelled on the existing `FlateDecode` static helper -- [ ] 2.2 Implement the decode state machine: accept hex digits + whitespace, terminate at `>`, pad odd trailing nibble with `0`, fail-safe on illegal characters via `p_error()` + return raw input +- [x] 2.1 Add `protected static function ASCIIHexDecode($_stream, $params)` in `src/PDFObject.php`, modelled on the existing `FlateDecode` static helper +- [x] 2.2 Implement the decode state machine: accept hex digits + whitespace, terminate at `>`, pad odd trailing nibble with `0`, fail-safe on illegal characters via `p_error()` + return `false` (the chain dispatcher's `=== false` short-circuit contract; matches upstream `p_error`'s default return) ## 3. Encode helper -- [ ] 3.1 Add `protected static function ASCIIHexEncode($_stream, $params)` — emit uppercase hex pairs, line-wrap at 80 columns, terminate with `>` -- [ ] 3.2 Handle the empty-input edge case (emit just `>`) +- [x] 3.1 Add `protected static function ASCIIHexEncode($_stream, $params)` — emit uppercase hex pairs, line-wrap at 80 columns, terminate with `>` +- [x] 3.2 Handle the empty-input edge case (emit just `>`) ## 4. Chain-dispatch integration -- [ ] 4.1 Add a `case '/ASCIIHexDecode'` arm to `apply_filter_chain_decode` (introduced in upstream-PR #05) routing to `self::ASCIIHexDecode(...)` -- [ ] 4.2 Add the symmetric arm in `apply_filter_chain_encode` routing to `self::ASCIIHexEncode(...)` +- [x] 4.1 Add a `case 'ASCIIHexDecode'` arm to `apply_filter_chain_decode` (introduced in upstream-PR #05; note the dispatcher strips the leading `/` from filter names before the switch — case label is the bare name without slash) routing to `self::ASCIIHexDecode(...)` +- [x] 4.2 Add the symmetric arm in `apply_filter_chain_encode` routing to `self::ASCIIHexEncode(...)` ## 5. Tests / verification -- [ ] 5.1 Add a round-trip fixture in `examples/poc-filter-roundtrip-asciihex.php`: random 1024-byte buffer → encode → decode → assert byte-for-byte equality -- [ ] 5.2 Verify the existing `examples/poc-replace-text.php` still exits 0 (FlateDecode-only path unchanged) -- [ ] 5.3 Add a chain test exercising `[/ASCIIHexDecode /FlateDecode]` end-to-end (encode plaintext → assert decoded matches) -- [ ] 5.4 Add a negative test asserting `p_error` on an illegal-character input and stream-unchanged behaviour +- [x] 5.1 Add a round-trip fixture in `examples/poc-filter-roundtrip-asciihex.php`: random 1024-byte buffer → encode → decode → assert byte-for-byte equality +- [x] 5.2 Verify the existing `examples/poc-replace-text.php` still exits 0 (FlateDecode-only path unchanged) +- [x] 5.3 Add a chain test exercising `[/ASCIIHexDecode /FlateDecode]` end-to-end (encode plaintext → assert decoded matches) +- [x] 5.4 Add a negative test asserting `p_error` on an illegal-character input and stream-unchanged behaviour ## 6. Upstream-PR draft -- [ ] 6.1 Update `docs/upstream-prs/01-asciihex-decode/proposal.md` with the spec REQ references -- [ ] 6.2 Update `docs/upstream-prs/01-asciihex-decode/design.md` with D1–D5 decision log -- [ ] 6.3 Update `docs/upstream-prs/01-asciihex-decode/tasks.md` with the implementation summary -- [ ] 6.4 Leave `Posted at: ` placeholder +- [x] 6.1 Update `docs/upstream-prs/01-asciihex-decode/proposal.md` with the spec REQ references +- [x] 6.2 Update `docs/upstream-prs/01-asciihex-decode/design.md` with D1–D5 decision log +- [x] 6.3 Update `docs/upstream-prs/01-asciihex-decode/tasks.md` with the implementation summary +- [x] 6.4 Leave `Posted at: ` placeholder ## 7. Quality gate -- [ ] 7.1 PHP 7.4 compatibility check (no typed properties, no enums, no readonly) -- [ ] 7.2 No new composer dependencies -- [ ] 7.3 snake_case method discipline (`ASCIIHexDecode` / `ASCIIHexEncode` follow upstream's PascalCase-on-filter-names + snake_case-on-utility-method convention; verify against `FlateDecode`) +- [x] 7.1 PHP 7.4 compatibility check (no typed properties, no enums, no readonly) +- [x] 7.2 No new composer dependencies +- [x] 7.3 snake_case method discipline (`ASCIIHexDecode` / `ASCIIHexEncode` follow upstream's PascalCase-on-filter-names + snake_case-on-utility-method convention; verify against `FlateDecode`) ## 8. Commit + PR -- [ ] 8.1 Commit on `feat/asciihex-decode` -- [ ] 8.2 Open PR `feat/asciihex-decode → work/text-replacement` +- [x] 8.1 Commit on `feat/asciihex-decode` +- [x] 8.2 Open PR `feat/asciihex-decode → work/text-replacement` diff --git a/src/PDFObject.php b/src/PDFObject.php index c2f9b62..9247e9f 100644 --- a/src/PDFObject.php +++ b/src/PDFObject.php @@ -209,6 +209,89 @@ protected static function FlateDecode($_stream, $params) { return $decoded->get_raw(); } + /** + * Decode an ASCIIHexDecode-encoded stream per PDF 1.7 §7.4.2. + * + * Accepts the alphabet `0..9 A..F a..f` plus whitespace plus the + * EOD marker `>`. Whitespace is stripped before parsing. Odd-length + * input is padded with a `0` nibble per §7.4.2 ¶3. Bytes after `>` + * are ignored. Illegal characters → `p_error()` + return `false` + * (matching the chain dispatcher's `=== false` fail-safe and + * upstream `p_error`'s default return semantics; the chain arm + * MUST short-circuit on failure so downstream filters do not see + * corrupted bytes). + * + * @param string $_stream Encoded stream bytes. + * @param mixed $params Unused (ASCIIHexDecode takes no parameters per Table 5). + * + * @return string|false Decoded binary bytes on success; `false` on + * illegal-character or hex2bin failure so the + * filter chain aborts cleanly. + */ + protected static function ASCIIHexDecode($_stream, $params) { + // Find the EOD marker; everything after `>` is ignored. EOD is + // mandatory per §7.4.2 but we tolerate a missing EOD by treating + // the whole input as the data region (matches Adobe Reader). + $eodPos = strpos($_stream, '>'); + $hexRegion = $eodPos === false ? $_stream : substr($_stream, 0, $eodPos); + + // Strip the PDF whitespace set (§7.5.1: NUL, HT, LF, FF, CR, SP). + $compact = preg_replace('/[\\x00\\x09\\x0a\\x0c\\x0d\\x20]/', '', $hexRegion); + + // Validate the alphabet. + if (preg_match('/[^0-9A-Fa-f]/', $compact) === 1) { + p_error('ASCIIHexDecode: illegal character in encoded stream'); + return false; + } + + // Odd-length: pad the trailing nibble with `0` per §7.4.2 ¶3. + if ((strlen($compact) % 2) === 1) { + $compact .= '0'; + } + + // The alphabet check above guarantees `hex2bin` can't legitimately + // fail here — drop the historical `@` suppression so unexpected + // failures surface in PHP's error reporting. + $bytes = hex2bin($compact); + if ($bytes === false) { + // Defensive — paired-validation passed but hex2bin still + // disagreed. Fail-safe per the chain dispatcher contract. + p_error('ASCIIHexDecode: hex2bin failed despite alphabet validation'); + return false; + } + return $bytes; + } + + /** + * Encode binary bytes via ASCIIHexEncode per PDF 1.7 §7.4.2. + * + * Emits uppercase hex pairs terminated by the EOD marker `>`. Line + * wraps inserted at 80 columns (Adobe-conventional; readers MUST + * tolerate any width per §7.4.2). Empty input emits just `>`. + * + * @param string $_stream Raw binary input. + * @param mixed $params Unused. + * + * @return string Encoded stream including EOD marker. + */ + protected static function ASCIIHexEncode($_stream, $params) { + $hex = strtoupper(bin2hex($_stream)); + if ($hex === '') { + return '>'; + } + // Chunk at 80 columns and re-join with newlines. + $wrapped = chunk_split($hex, 80, "\n"); + // chunk_split appends a trailing separator; trim it before the EOD. + $wrapped = rtrim($wrapped, "\n"); + // If the final hex chunk lands exactly on the 80-col boundary, + // appending `>` would push that line to 81 chars. Insert a + // newline before the EOD so no line exceeds 80. + if (strlen($hex) % 80 === 0) { + return $wrapped . "\n>"; + } + return $wrapped . '>'; + } + /** * Normalise the `/Filter` entry to a plain array of filter-name strings. * @@ -351,6 +434,13 @@ protected static function apply_filter_chain_decode($bytes, array $filters, arra } $bytes = $decoded; break; + case 'ASCIIHexDecode': + $decoded = self::ASCIIHexDecode($bytes, $params[$i] ?? null); + if ($decoded === false) { + return false; + } + $bytes = $decoded; + break; default: p_error("unknown compression method /$name in filter chain at position $i"); return false; @@ -384,6 +474,9 @@ protected static function apply_filter_chain_encode($bytes, array $filters, arra } $bytes = $compressed; break; + case 'ASCIIHexDecode': + $bytes = self::ASCIIHexEncode($bytes, $params[$i] ?? null); + break; default: p_error("unknown compression method /$name in filter chain at position $i"); return false;