Skip to content

feat(ascii85): ASCII85Decode encode + decode + chain wiring (PR #03, stacked on #5)#6

Merged
rjzondervan merged 4 commits into
feat/runlength-decodefrom
feat/ascii85-decode
May 28, 2026
Merged

feat(ascii85): ASCII85Decode encode + decode + chain wiring (PR #03, stacked on #5)#6
rjzondervan merged 4 commits into
feat/runlength-decodefrom
feat/ascii85-decode

Conversation

@rjzondervan

Copy link
Copy Markdown
Member

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

Helper (on PDFObject, protected static) Purpose
ASCII85Decode($_stream, $params): string|false Base-85 decode with z shortcut, Adobe-tolerant <~ strip, whitespace ignored, overflow guard, illegal-char fail-safe
ASCII85Encode($_stream, $params): string 4-byte → 5-char groups, z shortcut on aligned zero runs, partial-group padding, ~> EOD

Both wired into apply_filter_chain_decode / apply_filter_chain_encode via case 'ASCII85Decode':.

Spec contract

Pinned by openspec/changes/feat-ascii85-decode/:

  • REQ-1 — Decode per PDF 1.7 §7.4.3 (z shortcut, standard 5-char, Adobe leading <~, whitespace, partial-group)
  • REQ-2 — Encode produces round-trip-compatible output (z shortcut on encode, empty input, partial group)
  • REQ-3 — Lossless round-trip on arbitrary binary input (1024-byte random + every partial-group length 1-9 + zero-padded-mixed)
  • REQ-4 — Illegal characters fail safely
  • REQ-5 — Chain dispatcher recognises /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)
  • All prior gates still green (poc-replace-text.php, poc-filter-chain-roundtrip.php, poc-filter-roundtrip-asciihex.php, poc-filter-roundtrip-runlength.php)
  • php -l clean on touched files
  • No new composer dependencies; PHP 7.4 compat preserved (32-bit int overflow handled defensively)

Files changed (480 +, 50 −)

  • src/PDFObject.php — ASCII85Decode + ASCII85Encode helpers, dispatcher arms
  • examples/poc-filter-roundtrip-ascii85.php — new round-trip gate
  • docs/upstream-prs/03-ascii85-decode/{proposal,design,tasks}.md — implementation notes
  • openspec/changes/feat-ascii85-decode/tasks.md — 27/27 complete

Dependencies + ordering

Out of scope

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.
Comment thread src/PDFObject.php
Comment thread examples/poc-filter-roundtrip-ascii85.php Outdated
Comment thread src/PDFObject.php Outdated
Comment thread src/PDFObject.php Outdated
Comment thread src/PDFObject.php Outdated
Comment thread src/PDFObject.php
}

// Compute the 32-bit unsigned big-endian integer.
$n = ($b0 << 24) | ($b1 << 16) | ($b2 << 8) | $b3;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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;.

Comment thread src/PDFObject.php
Comment thread examples/poc-filter-roundtrip-ascii85.php
Comment thread examples/poc-filter-roundtrip-ascii85.php
Comment thread examples/poc-filter-roundtrip-ascii85.php
Comment thread examples/poc-filter-roundtrip-ascii85.php
Comment thread examples/poc-filter-roundtrip-ascii85.php
Comment thread docs/upstream-prs/03-ascii85-decode/design.md
Comment thread src/PDFObject.php
Comment thread src/PDFObject.php
Comment thread src/PDFObject.php
Comment thread src/PDFObject.php
Comment thread openspec/changes/feat-ascii85-decode/tasks.md

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

(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 WilcoLouwerse left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rjzondervan rjzondervan merged commit 845e163 into feat/runlength-decode May 28, 2026
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(lzw): LZWDecode + FlateDecode predictor refactor (PR #4, stacked on #6)
rjzondervan added a commit that referenced this pull request May 28, 2026
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.
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(cmap): ToUnicode CMap + text-space matching (PR #6, stacked on #7)
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(ascii85): /ASCII85Decode filter (SAPP PR #3)
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(cmap): ToUnicode CMap + FontEncoding + text-space matching (SAPP PR #6)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants