feat(ascii85): ASCII85Decode encode + decode + chain wiring (PR #03, stacked on #5)#6
Conversation
Implements PDF 1.7 §7.4.3 ASCII85Decode on top of the chain dispatcher from PR #5. Third of four filter PRs (#1 ASCIIHex, #2 RunLength, this, #4 LZW); orthogonal to all siblings via the standard `case` arm pattern in apply_filter_chain_{decode,encode}. New helpers on PDFObject (both `protected static`, PascalCase per upstream's filter-name convention): - ASCII85Decode($_stream, $params): string|false Adobe-tolerant leading `<~` strip (OQ1); whitespace ignored anywhere; `z` shortcut decodes to 4 zero bytes; standard 5-char groups in `!..u` → 4 binary bytes via base-85; trailing partial group of k chars padded with `u` (84) to 5 chars and emitted as k-1 binary bytes per §7.4.3 ¶4. Overflow guard: any group exceeding `uuuuu` (= 2^32 - 1) → p_error + raw input return. Illegal characters (outside `!..u z` + whitespace + EOD) similarly fail-safe. - ASCII85Encode($_stream, $params): string Aligned 4-zero-byte groups → `z` (D3 greedy). Full 4-byte groups → 5 chars MSB-first. Trailing partial group right-pads with \x00 to 4 bytes, encodes, emits only (remaining + 1) chars (the spec's partial-group convention). Always trails with `~>` EOD; empty input → just `~>`. Bit-shift overflow handled for 32-bit PHP platforms via `& 0xFFFFFFFF` + signed- to-unsigned promote. Dispatcher `case 'ASCII85Decode':` arms added to both encode + decode chain helpers. Contract pinned by openspec/changes/feat-ascii85-decode/ (proposal + design D1-D5 + spec REQ-1 through REQ-5 with MODIFIED filter-chain- dispatch capability + tasks). Verification: - examples/poc-filter-roundtrip-ascii85.php (new) — 5 REQs: z shortcut decode/encode, standard 5-char group, Adobe-tolerant `<~`, whitespace handling, 1024-byte random + partial-group lengths {1,2,3,5,6,7,9} + zero-padded-mixed lossless round-trip, illegal-char fail-safe, chain integration (single-filter + ASCII85+Flate two-filter outer-envelope). - examples/poc-replace-text.php — baseline still green. - examples/poc-filter-chain-roundtrip.php — dispatcher tests green. - examples/poc-filter-roundtrip-asciihex.php — sibling unaffected. - examples/poc-filter-roundtrip-runlength.php — sibling unaffected. Closes openspec/changes/feat-ascii85-decode/tasks.md 27/27.
| } | ||
|
|
||
| // Compute the 32-bit unsigned big-endian integer. | ||
| $n = ($b0 << 24) | ($b1 << 16) | ($b2 << 8) | $b3; |
There was a problem hiding this comment.
🟡 Concern — Defensive overflow code is dead-and-broken
Lines 339-346 implement a defensive 32-bit overflow path with $n = $n & 0xFFFFFFFF; and if ($n < 0) { $n += 4294967296; }. This is dead code on 64-bit PHP (impossible to negative) and BROKEN on 32-bit PHP (the literal 0xFFFFFFFF is parsed as float, bitwise-AND coerces to int, breaking the math). Since composer.json effectively requires 64-bit ints, the defensive paths should be deleted with a comment that 64-bit ints are required.
Suggested fix: Delete the defensive code; add a one-line comment that 64-bit ints are required.
There was a problem hiding this comment.
❌ Not yet resolved — the fix removed the & 0xFFFFFFFF + if ($n < 0) pattern from the full-group encoder path (now src/PDFObject.php:373-379, correctly replaced with a comment explaining why), but the identical pattern persists in the trailing-partial-group block at src/PDFObject.php:400-401:
$n = (($b0 << 24) | ($b1 << 16) | ($b2 << 8) | $b3) & 0xFFFFFFFF;
if ($n < 0) { $n += 4294967296; }The fix-commit message states the defensive code was "Deleted with comment that 64-bit ints are required" — but the deletion only covers one of the two arithmetic blocks in ASCII85Encode. Same arguments apply: dead on 64-bit (input is bounded by 0xFFFFFFFF after the OR of four bytes), broken on 32-bit (the literal parses as float and the bitwise-AND coerces, breaking the result). Either delete both or document why the trailing-partial path needs different handling.
Suggested fix: apply the same change to lines 400-401 — remove the mask + the signed-promote branch, leaving $n = ($b0 << 24) | ($b1 << 16) | ($b2 << 8) | $b3;.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Strict-mode review (REQUEST_CHANGES)
Functionally close to spec but contains a bogus PHPDoc claim, a spec-violating decode of 1-char partial groups, wrong-by-one test inputs that mask REQ-1 coverage, an unreachable-on-success === false chain guard, a misleading return $_stream semantic, and several minor doc/test gaps.
Findings: 🔴 3 blockers · 🟡 10 concerns · 🟢 5 minors. All findings have been posted as inline comments above. Per Strict-mode rules, any 🟡 or 🔴 holds the verdict; address each thread before requesting re-review.
🔴 Blockers
- src/PDFObject.php:295 — 1-char partial group on decode silently accepted (spec violation)
- examples/poc-filter-roundtrip-ascii85.php:134 — REQ-1 test inputs are malformed — 6-char
87cURDnot 5-char87cUR— and only pass because of finding #1 - src/PDFObject.php:282 — PHPDoc claims
uuuuu= 2^32-1 — wrong; actual max-valid group iss8W-!
(Inline comments include **Impact:** and **Suggested fix:** sections per Strict-mode body template.)
Blockers:
- ASCII85Decode silently accepted 1-char trailing partial groups,
emitting 0 bytes via `$emit = $groupLen - 1` when $groupLen=1.
PDF 1.7 §7.4.3 partial-group rule requires `2 ≤ k ≤ 4`. Now
rejects with p_error + `return false`.
- REQ-1 test fixtures used `87cURD~>` claiming it encodes "Hell"
— actual canonical encoding is `87cUR~>` (5 chars). The `D` was
a stray 1-char partial that the spec-violation above accepted as
zero bytes. Test now uses verified canonical encodings.
- PHPDoc + design + proposal + tasks all claimed `uuuuu` = exactly
2^32-1. Arithmetic shows `uuuuu` = 84*(85^4+85^3+85^2+85+1) =
4,437,053,124, which is 142,085,829 OVER the 2^32-1 cap. The
spec-imposed max 5-char group is actually `s8W-!`. Comments and
docs now state this correctly; overflow guard text rewritten.
Concerns:
- Same dead-code pattern as ASCIIHex/RunLength: all decoder failure
paths now `return false` (not `$_stream`), matching dispatcher
contract.
- Error path was returning the post-`<~`-strip mutated stream as
"raw input" — moot now that all failure paths return false.
- Defensive 32-bit overflow code in encoder (`& 0xFFFFFFFF` + signed
promote) was dead on 64-bit PHP and broken on 32-bit (literal
`0xFFFFFFFF` parses as float on 32-bit, bitwise-AND coerces and
breaks the math). Deleted with comment that 64-bit ints are
required (upstream composer constraint already guarantees this).
- preg_replace whitespace-strip now null-guarded (PCRE compile
failure / limit → p_error + return false, no PHP 8.1+ deprecation
on strlen(null)).
- New negative tests: `~` mid-stream, `z` mid-group, 1-char partial
group, overflow guard fires on `tttt~>` (yields 4,384,231,064 >
2^32-1).
- Boundary coverage extended to n=1..9 (previously skipped
multiples of 4 — exactly the boundary between full-group and
partial-group code paths).
- New empty-payload edge cases: `<~~>` and bare `~>`.
- New chain-failure-propagation test (outer ASCII85 illegal char
MUST short-circuit before inner FlateDecode runs).
Minors:
- docs/upstream-prs/03-ascii85-decode/{proposal,design,tasks}.md:
replaced 16-line copy-pasted "Implementation note" blocks with
pointers to canonical openspec/changes/feat-ascii85-decode/
(same pattern as PRs #1/#2/#5).
- spec.md REQ-001 rewritten with verified canonical byte literals
(Hell, Hello, Hello world!); REQ-004 expanded to cover all 6
spec-violation paths.
Verification: all 5 gates green (poc-replace-text,
poc-filter-chain-roundtrip, poc-filter-roundtrip-asciihex,
poc-filter-roundtrip-runlength, poc-filter-roundtrip-ascii85).
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Re-review: all 3 prior 🔴 blockers + 9/10 prior 🟡 concerns landed cleanly in 66baeaf (canonical encodings, return false everywhere, s8W-! correction, exhaustive boundary tests, propagation test, doc cleanup).
One residual 🟡 — the encoder's trailing-partial-group block at src/PDFObject.php:400-401 still has the same & 0xFFFFFFFF + if ($n < 0) pattern that the fix-commit message said was deleted (the full-group path at 373-379 was correctly cleaned, the parallel block 20 lines below was missed). Reply with details on the original #6 thread.
Standard-mode verdict: APPROVE — dead-on-64-bit + theoretically-broken-on-32-bit isn't a merge blocker, but please mirror the deletion in a follow-up commit before stacking PR #4 on top of this.
Blockers: - Only the first match per TJ operator was processed. processTjArray returned on the first hit; outer caller advanced past the entire TJ. Multi-needle TJs and same-needle-twice-in-one-TJ under-counted replacements_per_needle. Rewrote with an outer while-loop that re-resolves fragment texts after each splice and continues until no remaining needle matches. New REQ-006 in spec.md anchors the contract with two-needle and same-needle- twice scenarios. - Missing isset check on $stats['replacements_per_needle'][$needle]++ in processTjArray. Lazy-init kept as a belt-and-braces guard (the entry point already pre-keys via array_fill_keys, but the guard survives future entry-point refactors). - Odd-length hex silently dropped a char (@hex2bin($hex) ?: ''). PDF 1.7 §7.3.4.3 mandates implicit trailing-zero padding. Now pads via `if (strlen($hex) & 1) $hex .= '0'`. New REQ-005 scenario covers this. - Non-spec leniency: numeric tokenizer accepted [-+\d.eE]. PDF 1.7 §7.3.3 forbids exponent notation in Numeric Objects. Tightened to [-+.0-9] so a producer's `e` glyph after a hex CID can't be swallowed as part of a number. New REQ-005 scenario covers this. - Phantom-ticked tasks 5.4 and 5.5 (no fixtures). Reverted to unchecked with explicit deferral notes. Concerns: - Dead $matchedOnce assignment removed via the rewrite. - Comments not treated as whitespace inside TJ arrays. Now skipped per PDF 1.7 §7.2.4. REQ-005 scenario. - tj_arrays_modified diagnostic key now pre-initialised at top of replace_text_in_document (contract no longer fragile). - preg_match-per-byte for whitespace replaced with strpos against the 6-byte PDF whitespace set (~50× faster on large TJ arrays). - Shape inheritance: placeholder now always emitted as literal regardless of first matched fragment's shape (matches D2 documented contract). - `@hex2bin` defensive `@` removed — the odd-length check above makes the warning unreachable; PHP errors for other malformed shapes now surface to callers explicitly. - end() / reset() pointer-advancing avoided in the new render path (group-based emission uses numeric indexing). - Triplicate Implementation Note blocks in docs/upstream-prs/07-tj- flattening/{proposal,design,tasks}.md replaced with canonical pointers (same pattern as PRs #1/#2/#3/#4/#5/#6). - Tf set inside q/Q leaks documented as a known gap (q/Q stack not maintained); deferred to a follow-up. Verification: all 7 gates green (poc-replace-text, poc-tounicode-cmap, poc-tj-flattening, plus the 4 filter PoCs). Also fixed in this commit: poc-tj-flattening.php was calling $doc->replaceTextInDocument() (camelCase) which doesn't exist after PR #1's snake_case rename. Updated to replace_text_in_document.
feat(ascii85): /ASCII85Decode filter (SAPP PR #3)
feat(cmap): ToUnicode CMap + FontEncoding + text-space matching (SAPP PR #6)
Summary
Implements PDF 1.7 §7.4.3 ASCII85Decode as the third of four filter PRs that attach to the chain dispatcher from #3. Orthogonal to ASCIIHexDecode (#4) and RunLengthDecode (#5).
Stacked on #5 feat/runlength-decode.
What landed
PDFObject,protected static)ASCII85Decode($_stream, $params): string|falsezshortcut, Adobe-tolerant<~strip, whitespace ignored, overflow guard, illegal-char fail-safeASCII85Encode($_stream, $params): stringzshortcut on aligned zero runs, partial-group padding,~>EODBoth wired into
apply_filter_chain_decode/apply_filter_chain_encodeviacase 'ASCII85Decode':.Spec contract
Pinned by
openspec/changes/feat-ascii85-decode/:<~, whitespace, partial-group)/ASCII85Decode(MODIFIED capability from feat(filter-chain): array-form /Filter dispatch in PDFObject (PR #05 — foundation) #3)Test plan
php examples/poc-filter-roundtrip-ascii85.php— 5 REQs, 0 assertion failures (including partial-group lengths 1, 2, 3, 5, 6, 7, 9 and zero-padded-mixed)poc-replace-text.php,poc-filter-chain-roundtrip.php,poc-filter-roundtrip-asciihex.php,poc-filter-roundtrip-runlength.php)php -lclean on touched filesFiles changed (480 +, 50 −)
src/PDFObject.php— ASCII85Decode + ASCII85Encode helpers, dispatcher armsexamples/poc-filter-roundtrip-ascii85.php— new round-trip gatedocs/upstream-prs/03-ascii85-decode/{proposal,design,tasks}.md— implementation notesopenspec/changes/feat-ascii85-decode/tasks.md— 27/27 completeDependencies + ordering
Out of scope
/DecodeParms— none defined for ASCII85Decode per PDF 1.7 Table 5<~start marker on encode (only stripped on decode; emit never)