Skip to content

feat(runlength): RunLengthDecode encode + decode + chain wiring (PR #02, stacked on #4)#5

Merged
rjzondervan merged 5 commits into
feat/asciihex-decodefrom
feat/runlength-decode
May 28, 2026
Merged

feat(runlength): RunLengthDecode encode + decode + chain wiring (PR #02, stacked on #4)#5
rjzondervan merged 5 commits into
feat/asciihex-decodefrom
feat/runlength-decode

Conversation

@rjzondervan

Copy link
Copy Markdown
Member

Summary

Implements PDF 1.7 §7.4.5 RunLengthDecode as the second of four filter PRs that attach to the chain dispatcher from #3. Orthogonal to ASCIIHexDecode (#4); the two add their own case arms and don't interact.

Stacked on #4 feat/asciihex-decode. This PR's base will auto-retarget to feat/filter-chain-dispatchwork/text-replacement as the stack rolls forward.

What landed

Helper (on PDFObject, protected static) Purpose
RunLengthDecode($_stream, $params): string|false State machine for literal blocks (L ∈ [0,127]), EOD (L = 128), repeat blocks (L ∈ [129,255] → repeat byte 257-L times); fail-safe on truncation
RunLengthEncode($_stream, $params): string Greedy run-or-literal accumulator; caps at 128 per block; always emits trailing \x80 EOD

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

Spec contract

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

  • REQ-1 — RunLengthDecode SHALL decode per PDF 1.7 §7.4.5 (literal-run, repeat-run, mixed, EOD-halts)
  • REQ-2 — RunLengthEncode SHALL produce a valid round-trip-compatible stream (literal, repeat, empty input)
  • REQ-3 — Lossless round-trip on arbitrary binary input
  • REQ-4 — Truncated input SHALL fail safely (truncated literal, truncated repeat, missing EOD all → p_error + raw passthrough)
  • REQ-5 — Chain dispatcher recognises /RunLengthDecode (MODIFIED capability from feat(filter-chain): array-form /Filter dispatch in PDFObject (PR #05 — foundation) #3)

Spec correction during impl

Original REQ-2 literal-only scenario used "Hello" — but "Hello" contains "ll", which the greedy encoder correctly detects as a repeat block. Updated the spec scenario to use any 5-byte string with no adjacent duplicates ("abcde" in the test) so the literal-only-block contract is actually verifiable. Caught by the round-trip test failing during implementation.

Test plan

  • php examples/poc-filter-roundtrip-runlength.php — 5 REQs, 0 assertion failures
  • php examples/poc-replace-text.php — baseline still green
  • php examples/poc-filter-chain-roundtrip.php — dispatcher tests green
  • php examples/poc-filter-roundtrip-asciihex.php — sibling filter unaffected
  • php -l clean on touched files
  • No new composer dependencies; PHP 7.4 compat preserved

Files changed (436 +, 47 −)

  • src/PDFObject.php — RunLengthDecode + RunLengthEncode helpers, dispatcher arms
  • examples/poc-filter-roundtrip-runlength.php — new round-trip gate
  • docs/upstream-prs/02-runlength-decode/{proposal,design,tasks}.md — implementation notes
  • openspec/changes/feat-runlength-decode/{tasks.md,specs/.../spec.md} — 22/22 complete + literal-only scenario fix

Dependencies + ordering

Out of scope

… PR #2)

Implements PDF 1.7 §7.4.5 RunLengthDecode on top of the chain dispatcher
from PR #5. Adds two `case` arms in apply_filter_chain_decode /
apply_filter_chain_encode — orthogonal to ASCIIHexDecode (#1); the
two filter PRs do not interact.

New helpers on PDFObject (both `protected static`, PascalCase per
upstream's filter-name convention):

  - RunLengthDecode($_stream, $params): string|false
      Single-pass state machine. Literal blocks (L ∈ [0,127] → copy
      L+1 bytes), EOD (L = 128 → stop), repeat blocks (L ∈ [129,255]
      → repeat next byte 257-L times). Bounds-checks every substr /
      byte read; truncation / missing EOD → p_error + return raw
      input.

  - RunLengthEncode($_stream, $params): string
      Greedy run-or-literal accumulator. Detects 2+ adjacent
      identical bytes as a repeat block (capped at 128 per block),
      otherwise accumulates a literal block (also capped at 128).
      Always emits trailing 0x80 EOD, including for empty input
      (output is a single byte) per OQ1.

Dispatcher `case 'RunLengthDecode':` arms added to both encode +
decode chain helpers.

Spec correction during impl: the original REQ-2 literal-only encode
scenario used `"Hello"` as the example — but `"Hello"` contains the
`"ll"` run, so the greedy encoder correctly emits a repeat block,
not a pure-literal block. Updated the spec scenario to use any
5-byte string with no adjacent duplicates (`"abcde"` in the test) so
the contract is verifiable.

Contract pinned by openspec/changes/feat-runlength-decode/
(proposal + design D1-D4 + spec REQ-1 through REQ-5 with MODIFIED
filter-chain-dispatch capability + tasks).

Verification:
  - examples/poc-filter-roundtrip-runlength.php (new) — 5 REQs:
    literal-run / repeat-run / mixed / EOD-halts decode scenarios;
    literal / repeat / empty encode scenarios; 1024-byte mixed-
    pattern lossless round-trip; 3 truncation fail-safes; chain
    integration (single-filter + RunLength outer / Flate inner).
  - examples/poc-replace-text.php — baseline still green.
  - examples/poc-filter-chain-roundtrip.php — dispatcher tests green.
  - examples/poc-filter-roundtrip-asciihex.php — sibling filter
    still green (#1 unaffected).

Closes openspec/changes/feat-runlength-decode/tasks.md 22/22.
Comment thread src/PDFObject.php
Comment thread openspec/changes/feat-runlength-decode/tasks.md
Comment thread src/PDFObject.php
@@ -420,6 +539,13 @@ protected static function apply_filter_chain_decode($bytes, array $filters, arra
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(Anchored to nearest diff line src/PDFObject.php:539; finding concerns src/PDFObject.php:488.)

🟡 Concern — Greedy run detection bloats output

The literal-accumulation peek-ahead breaks out of a literal block as soon as ANY 2-byte run is detected. For input "ABBC", this emits \x00A\xFFB\x00C\x80 = 7 bytes; a single 4-byte literal \x03ABBC\x80 would be 6 bytes. The PR body acknowledges 'suboptimal vs Lempel-Ziv-style by ~5% on pathological inputs' but the threshold for breaking to repeat mode should arguably be 3+ adjacent identical bytes.

Suggested fix: Either raise the run threshold to 3, or add a dedicated ABBCDDEF-style fixture to REQ-2 documenting the size regression.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Still open — encoder bloat unaddressed

The blocker and most concerns are fixed, but the encoder's 1-byte peek that aborts a literal block on the first 2-byte run is unchanged. For input "ABBC" the encoder still emits \x00A\xFFB\x00C\x80 (7 bytes) instead of the 6-byte literal \x03ABBC\x80. Prior suggested fix — raise the run threshold to 3, OR add an ABBC-style fixture to REQ-2 documenting the regression — remains valid.

Non-blocker under Standard mode. Suggest a // TODO(opt): + follow-up issue, OR a fixture in this PR — either is fine. Leaving the thread open for the follow-up.

Comment thread src/PDFObject.php
Comment thread docs/upstream-prs/02-runlength-decode/design.md
Comment thread docs/upstream-prs/02-runlength-decode/design.md
Comment thread src/PDFObject.php
Comment thread examples/poc-filter-roundtrip-runlength.php
Comment thread openspec/changes/feat-runlength-decode/specs/runlength-decode-filter/spec.md Outdated
echo "RESULT: VERIFIED — all 5 REQs covered, no assertion failures.\n";
exit(0);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Minor — Error-channel assumption

Lines wrap each truncation call in ob_start() / ob_end_clean() to silence p_error. Doesn't assert that p_error was actually called.

Suggested fix: Use ob_get_clean() and assert the captured string contains the expected error fragment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Still open — p_error not asserted (optional)

The fix added the chain-failure-propagation test (good), but the existing truncation tests at examples/poc-filter-roundtrip-runlength.php:152-154, 161-163, 170-172, 192-194 still pair ob_start() with ob_end_clean(), discarding the captured output. A regression where p_error() stops being called (e.g. someone replaces it with a silent return false) would still pass.

Optional — fine to defer to a follow-up.

Comment thread src/PDFObject.php
@@ -420,6 +539,13 @@ protected static function apply_filter_chain_decode($bytes, array $filters, arra
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(Anchored to nearest diff line src/PDFObject.php:539; finding concerns src/PDFObject.php:497.)

🟢 Minor — No defensive guard

Static analysis: literal mode is only entered when $runLen < 2, so the first iteration cannot break immediately. Safe in practice. A defensive assert($literalLen > 0) would survive future refactors of the surrounding logic.

Suggested fix: Add a one-line guard.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Still open — defensive guard not added (optional)

Encoder literal-mode entry at src/PDFObject.php:318-321 still relies implicitly on $runLen < 2 falling through from the earlier branch. A one-line assert($literalLen >= 1 || $i >= $len) or a clarifying comment about the branch dependency would survive future refactors.

Optional — fine to defer.

foreach ($failures as $msg) {
echo " - $msg\n";
}
exit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(Anchored to nearest diff line examples/poc-filter-roundtrip-runlength.php:212; finding concerns examples/poc-filter-roundtrip-runlength.php:265.)

🟢 Minor — Two-filter chain test under-specifies structure

The two-filter [/RunLengthDecode /FlateDecode] test only verifies plaintext round-trip equality. Doesn't assert the encoded stream's last byte is \x80 (RunLength EOD at outer layer) or that the Flate-decoded inner layer is a valid Flate stream.

Suggested fix: Add substr($obj->get_stream(true), -1) === "\x80" assertion mirroring the single-filter case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Partial — happy-path structural assertion still missing

The new failure-path test at examples/poc-filter-roundtrip-runlength.php:183-199 proves the dispatcher short-circuits on outer-RLE truncation — nice addition. But the happy-path two-filter test at examples/poc-filter-roundtrip-runlength.php:224-241 still only asserts plaintext-equality on round-trip; nothing asserts the encoded stream ends with \x80 (outer RLE EOD).

Suggested fix unchanged: add a substr($obj->get_stream(true), -1) === "\x80" check mirroring the single-filter case at examples/poc-filter-roundtrip-runlength.php:214-216. Optional.

@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 correct RLE encoder/decoder with verifiable round-trip, but ships a silent-fail dispatcher bug (decoder never returns false despite docstring claim, making the chain's failure short-circuit dead code), several documentation inconsistencies, and a non-optimal 2-byte-run encoding that bloats output without test coverage.

Findings: 🔴 1 blockers · 🟡 4 concerns · 🟢 7 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:333 — Decoder returns raw $_stream on failure but PHPDoc / dispatcher expect false → dead-code short-circuit, silent data corruption in filter chain

(Inline comments include **Impact:** and **Suggested fix:** sections per Strict-mode body template.)

Blocker:
- RunLengthDecode: 3 failure paths return $_stream → return false. The
  chain dispatcher's `=== false` short-circuit was dead code; downstream
  filters could see partial output. Matches ASCIIHexDecode's fix pattern.

Concerns:
- spec.md REQ-4: return-value semantics now say false (not raw input);
  added missing-EOD scenario + chain-failure-propagation scenario.
- spec.md REQ-2 literal-only: note that "Hello" doesn't qualify as
  literal-only — its `ll` pair is a 2-byte run the greedy encoder
  flushes as a separate repeat block.
- PoC: 3 truncation tests now expect false; new chain-failure-
  propagation test (outer truncated RLE + inner Flate; dispatcher
  MUST return false before Flate runs).
- PoC: 128-byte and 256-byte boundary tests for the repeat-block
  cap (max block length per PDF 1.7 §7.4.5).
- PHPDoc: documents the 64× decode-amplification trust assumption
  (caller is responsible for input bounds; explicit cap is a follow-up).
- tasks.md: dispatcher case label is bare 'RunLengthDecode' (no slash).

Minors:
- docs/upstream-prs/02-runlength-decode/{proposal,design,tasks}.md:
  replaced duplicated 12-line "Implementation note" blocks with a
  pointer to the canonical openspec/changes/feat-runlength-decode/
  design.md (single source of truth).

Verification: all 4 gates green (poc-replace-text,
poc-filter-chain-roundtrip, poc-filter-roundtrip-asciihex,
poc-filter-roundtrip-runlength).
…runlength-decode

# Conflicts:
#	openspec/changes/feat-runlength-decode/specs/runlength-decode-filter/spec.md
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(filter-chain): array-form /Filter dispatch in PDFObject (PR #5 — foundation)
rjzondervan added a commit that referenced this pull request May 28, 2026
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).
rjzondervan added a commit that referenced this pull request May 28, 2026
Blockers:
- All 4 LZWDecode error paths returned $_stream (the encoded bytes)
  while the docblock claimed @return string|false. The chain
  dispatcher's `=== false` check was dead code; truncated bit
  stream, dict overflow, KwKwK overflow, and out-of-range code all
  silently leaked encoded LZW bytes through the rest of the chain.
  All four now `return false` per the dispatcher contract.
- applyPngPredictor's docblock said `@return string|null` but the
  method only returned `string|false` (via `return p_error(...)`).
  LZWDecode guarded with `if ($predicted === null)` — always false,
  so `$out = $predicted` assigned `false` (a bool) to a string-typed
  output. Fixed the docblock + LZWDecode now checks `=== false`.

Concerns:
- Same dead-code dispatcher pattern as other filters: addressed by
  the blocker fix above.
- EarlyChange/decoder-lag comment rewritten: the `+1` in
  `($nextCode + 1) >= $threshold` is the decoder-lag correction
  (orthogonal to EarlyChange — both EarlyChange=0 and =1 work
  with it). Previous comment conflated the two.
- Pre-existing PNG Sub-filter bug (`$data[$i] = ($data[$i] +
  $data[$i-1]) % 256` doing string-arithmetic on single-char
  strings) now reached through LZW too. Fixed to use the same
  ord()/chr() wrapping the Up filter already had.
- applyPngPredictor docblock now states the documented carried-over
  limitations (Predictor=2 silently treated as <10, Colors=1 only,
  BitsPerComponent=8 only, Paeth/Average filter bytes rejected) so
  reviewers know LZW+Predictor=15 (auto-select) image streams may
  silently fail.
- Asymmetric encode-path/predictor for `set_stream(false)` with
  /Predictor=12 is a pre-existing chain-dispatcher concern,
  documented as out-of-scope for this PR.

Minors:
- New tests: truncated bit stream (single 0x80 byte), out-of-range
  code (300 with empty dict), predictor rejection (Predictor=15 +
  Colors=3 triggers applyPngPredictor → false propagation), Sub
  filter byte coverage (exercises the previously-broken string-
  arithmetic loop), chain-failure propagation (outer truncated
  LZW MUST short-circuit before inner Flate runs).
- docs/upstream-prs/04-lzw-decode/{proposal,design,tasks}.md: 34-line
  copy-pasted "Implementation note" blocks replaced with pointers to
  canonical openspec/changes/feat-lzw-decode/ (same pattern as PRs
  #1/#2/#3/#5).

Verification: all 6 gates green (poc-replace-text,
poc-filter-chain-roundtrip, poc-filter-roundtrip-{asciihex,
runlength, ascii85, lzw}).

---

> **Implementation note**: canonical design lives in `openspec/changes/feat-runlength-decode/design.md` (D1–D6). The truncation fail-safe ships as `return false` per the chain dispatcher's `=== false` short-circuit contract; this matches upstream `p_error()`'s default return and ensures downstream filters never see a partially-decoded buffer.

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 — canonical design.md D3 contradicts shipped code

This new pointer note advertises the canonical openspec design as the source of truth, but the canonical's D3 (at openspec/changes/feat-runlength-decode/design.md:36-38) still reads:

D3 — Decode rejects truncated input via p_error + return raw
p_error() is called and the raw input is returned unchanged.

That contradicts the shipped code (Thread 1 fix: 3 paths return false) and the updated spec.md REQ-4. The Risks table at openspec/.../design.md:49 also still says "raise p_error and return raw input on underflow".

Suggested fix: rename D3 heading to "Decode rejects truncated input via p_error + return false", rewrite the body to reference the chain dispatcher's === false short-circuit contract (mirroring the PHPDoc at src/PDFObject.php:220-224), and update the matching Risks row.

Non-blocker under Standard mode — but since the new pointer note explicitly directs readers there, the inconsistency is now visible to anyone following the link.


---

> **Implementation note**: canonical design lives in `openspec/changes/feat-runlength-decode/design.md` (D1–D6). The truncation fail-safe ships as `return false` per the chain dispatcher's `=== false` short-circuit contract; this matches upstream `p_error()`'s default return and ensures downstream filters never see a partially-decoded buffer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Minor — pointer overstates the canonical's decision count

This note says "canonical design lives in openspec/.../design.md (D1–D6)", but the canonical file only defines D1–D4. Same overcount in docs/upstream-prs/02-runlength-decode/proposal.md:24: "D1–D6 decisions, including the truncation fail-safe contract".

Suggested fix: either trim both pointer notes to "(D1–D4)", OR add D5 (chain-failure-propagation contract) and D6 (trust-amplification trade-off) to the canonical design as named decisions. Either is fine.

@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 verdict: APPROVE (Standard mode)

Blocker fully fixed end-to-end: decoder returns false on truncation (src/PDFObject.php:260,268,278), dispatcher short-circuits at === false (src/PDFObject.php:576-578), spec.md REQ-4 mandates the false-sentinel contract, and the new chain-failure-propagation PoC verifies it. Solid fix.

Prior 12 comments: 8 verified + resolved (1 🔴 + 3 🟡 + 4 🟢), 4 left open as polite follow-ups:

2 new findings flagged from this re-review (both non-blocking under Standard):

Finding A is the only one worth tackling before this lands upstream — the new pointer note advertises a canonical that contradicts itself, which is a worse state than before the fix. A small follow-up commit (rename D3 heading + update body + Risks row) would close it cleanly. The rest are fine as follow-ups.

Wilco's re-review on PR #5 flagged spec drift between the canonical
openspec design and the shipped code:

- D3 in openspec/changes/feat-runlength-decode/design.md still read
  "return raw input unchanged" while the shipped code (and updated
  spec.md REQ-4) return `false`. Rewrote D3 to match the chain
  dispatcher's `=== false` short-circuit contract and `p_error()`'s
  default return.
- Pointer notes in docs/upstream-prs/02-runlength-decode/ referenced
  "D1–D6" but the canonical only had D1–D4. Added D5 (chain-failure-
  propagation contract) and D6 (decode-amplification caller-trust
  contract) to the canonical design — both reflect the as-shipped
  behaviour with code references.
- Risks table updated: removed "return raw input" wording; added
  fixture-worthy `"ABBC"` example illustrating greedy-encoder bloat.

Other items from the re-review:
- Happy-path two-filter test now asserts the encoded stream's last
  byte is `\x80` (outer RLE EOD), mirroring the single-filter
  structural check. Guards against a dispatcher regression that
  silently bypasses the outer encoder.
- RunLengthEncode PHPDoc adds a TODO(opt) explaining the greedy
  run-detection threshold trade-off (2 vs. 3 adjacent identical
  bytes; the `"ABBC"` shape bloats by 1 byte).

Verification: all 4 gates green (poc-replace-text,
poc-filter-chain-roundtrip, poc-filter-roundtrip-asciihex,
poc-filter-roundtrip-runlength).
@rjzondervan rjzondervan merged commit d617333 into feat/asciihex-decode May 28, 2026
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(ascii85): ASCII85Decode encode + decode + chain wiring (PR #3, stacked on #5)
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(runlength): /RunLengthDecode filter (SAPP PR #2)
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