forked from dealfonso/sapp
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(filter-chain): array-form /Filter dispatch in PDFObject (PR #05 — foundation) #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rjzondervan
merged 5 commits into
work/text-replacement
from
feat/filter-chain-dispatch
May 28, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
becf3ad
poc(text-replacement): end-to-end verified text replacement on FlateD…
rjzondervan c30eda3
feat(filter-chain): array-form /Filter dispatch in PDFObject (SAPP PR…
rjzondervan 5a2c5c5
poc(text-replacement): address Wilco's first-pass strict review
rjzondervan bc4995e
feat(filter-chain): address Wilco's first-pass strict review
rjzondervan 7ba72ce
Merge remote-tracking branch 'origin/work/text-replacement' into feat…
rjzondervan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| <?php | ||
| /** | ||
| * Round-trip verification gate for the filter chain dispatcher | ||
| * (`feat-filter-chain-dispatch` / docs/upstream-prs/05-filter-chaining/). | ||
| * | ||
| * This script asserts the dispatcher contract from the OpenSpec change's | ||
| * spec.md: | ||
| * | ||
| * REQ-1 — Array-form /Filter SHALL decode in forward chain order | ||
| * REQ-2 — Array-form /Filter SHALL encode in reverse chain order | ||
| * REQ-3 — /DecodeParms array SHALL be applied positionally per filter | ||
| * REQ-4 — String-form /Filter callers MUST observe unchanged behaviour | ||
| * REQ-5 — Unknown filter names in a chain MUST fail safely | ||
| * | ||
| * Because the four follow-up filter PRs (#01-#04) haven't shipped yet, | ||
| * the chain-decode positive path is exercised with a single-element | ||
| * filter array `[/FlateDecode]` — semantically equivalent to the | ||
| * string form but routed through the chain dispatcher. The unknown- | ||
| * filter scenario verifies REQ-5 directly. | ||
| * | ||
| * 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; | ||
| use ddn\sapp\pdfvalue\PDFValueSimple; | ||
|
|
||
| $failures = []; | ||
|
|
||
| /** | ||
| * Build a PDFObject with the given /Filter shape and stream bytes. | ||
| * | ||
| * The stream is stored raw (no encoding) so the test owns the byte | ||
| * layout entirely. Callers that want compression use `set_stream($_, | ||
| * false)` on the returned object to round-trip through the dispatcher. | ||
| */ | ||
| 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-4 — string-form /Filter callers MUST observe unchanged behaviour | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $plaintext = "BT /F1 12 Tf 72 720 Td (Hello chain dispatch.) Tj ET"; | ||
|
|
||
| $obj = buildObj(new PDFValueType('FlateDecode')); | ||
| $obj->set_stream($plaintext, false); | ||
|
|
||
| $encoded = $obj->get_stream(true); | ||
| if ($encoded === $plaintext) { | ||
| $failures[] = "REQ-4: string-form FlateDecode set_stream did not compress (encoded matches plaintext)"; | ||
| } | ||
|
|
||
| $decoded = $obj->get_stream(false); | ||
| if ($decoded !== $plaintext) { | ||
| $failures[] = "REQ-4: string-form FlateDecode round-trip lost data (got " . strlen($decoded) . " bytes, expected " . strlen($plaintext) . ")"; | ||
| } | ||
|
|
||
| // D2 — string-form Filter must NOT flip to array on write-back. | ||
| $filterAfter = (string) $obj->get_value()['Filter']; | ||
| if ($filterAfter !== '/FlateDecode') { | ||
| $failures[] = "D2: string-form /Filter flipped on write-back (got '$filterAfter')"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-1 — array-form /Filter [/FlateDecode] decodes in forward order | ||
| * REQ-2 — array-form /Filter [/FlateDecode] encodes in reverse order | ||
| * (single-element chain — semantically equivalent to string form | ||
| * but routed through the chain dispatcher.) | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $plaintext = "BT (Single-element chain test) Tj ET"; | ||
|
|
||
| $filterList = new PDFValueList(); | ||
| $filterList->push(new PDFValueType('FlateDecode')); | ||
|
|
||
| $obj = buildObj($filterList); | ||
| $obj->set_stream($plaintext, false); | ||
|
|
||
| $decoded = $obj->get_stream(false); | ||
| if ($decoded !== $plaintext) { | ||
| $failures[] = "REQ-1/2: 1-element array-form FlateDecode round-trip lost data"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-3 — /DecodeParms positional routing | ||
| * | ||
| * Single-filter chain with explicit /DecodeParms. The dispatcher must | ||
| * walk the parallel parms array and hand the per-filter dict to the | ||
| * decoder. With Predictor=1 the PNG predictor is a no-op so the | ||
| * round-trip is byte-for-byte clean even though /DecodeParms is | ||
| * present — this is the smoke-test path. A trailing-null entry on | ||
| * /DecodeParms must be tolerated; an under-length array must be | ||
| * tolerated. | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $plaintext = "REQ-3 smoke test — Predictor=1 + explicit /Columns 4"; | ||
|
|
||
| $filterList = new PDFValueList(); | ||
| $filterList->push(new PDFValueType('FlateDecode')); | ||
|
|
||
| // /DecodeParms [<</Predictor 1 /Columns 4>>] — single-element | ||
| // parallel array with explicit no-op predictor. | ||
| $parmsDict = new PDFValueObject(); | ||
| $parmsDict['Predictor'] = new PDFValueSimple(1); | ||
| $parmsDict['Columns'] = new PDFValueSimple(4); | ||
| $parmsList = new PDFValueList(); | ||
| $parmsList->push($parmsDict); | ||
|
|
||
| $obj = buildObj($filterList); | ||
| $obj->get_value()['DecodeParms'] = $parmsList; | ||
| $obj->set_stream($plaintext, false); | ||
|
|
||
| $decoded = $obj->get_stream(false); | ||
| if ($decoded !== $plaintext) { | ||
| $failures[] = "REQ-3 (/DecodeParms positional smoke test): round-trip mismatch"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-1 (Empty array /Filter) — MUST be treated as no filtering | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $plaintext = "raw bytes — no filtering"; | ||
|
|
||
| $emptyList = new PDFValueList(); | ||
|
WilcoLouwerse marked this conversation as resolved.
|
||
|
|
||
| $obj = buildObj($emptyList); | ||
| $obj->set_stream($plaintext, false); | ||
|
|
||
| // Empty chain means the stream is stored verbatim; get_stream(false) | ||
| // must hand back the same bytes. | ||
| $stored = $obj->get_stream(true); | ||
| if ($stored !== $plaintext) { | ||
| $failures[] = "REQ-1 (Empty array): empty-chain set_stream did not store plaintext verbatim"; | ||
| } | ||
|
|
||
| $decoded = $obj->get_stream(false); | ||
| if ($decoded !== $plaintext) { | ||
| $failures[] = "REQ-1 (Empty array): empty-chain get_stream did not pass-through"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * REQ-5 — unknown filter names in a chain MUST fail safely | ||
| * (the dispatcher emits p_error and leaves _stream + Length | ||
| * unchanged; the method does NOT throw.) | ||
| * ---------------------------------------------------------------- */ | ||
| { | ||
| $originalCompressed = (string) gzcompress("doesn't matter — won't decode through the unknown filter"); | ||
|
|
||
| $unknownChain = new PDFValueList(); | ||
| // Use a deliberately-fake filter name so this test stays valid as | ||
| // PRs #01-#04 land real implementations of ASCIIHex / ASCII85 / | ||
| // RunLength / LZW. The dispatcher's fail-safe path is what's | ||
| // under test, not any particular filter's availability. | ||
| $unknownChain->push(new PDFValueType('SappTestUnknownFilter')); | ||
| $unknownChain->push(new PDFValueType('FlateDecode')); | ||
|
|
||
| $obj = buildObj($unknownChain, $originalCompressed); | ||
| $obj->get_value()['Length'] = strlen($originalCompressed); | ||
|
|
||
| // Capture stderr to swallow the p_error log — we know it will fire. | ||
| ob_start(); | ||
| $decoded = $obj->get_stream(false); | ||
| ob_end_clean(); | ||
|
|
||
| // REQ-5: get_stream returns `false` on unknown chain filter, matching | ||
| // upstream's pre-refactor `return p_error(...)` semantics. Callers | ||
| // using the `if ($decoded === false) continue;` idiom (e.g. | ||
| // PDFDoc::replace_text_in_document's not-is_string guard) get the | ||
| // skip behaviour they expect. | ||
| if ($decoded !== false) { | ||
| $failures[] = "REQ-5: get_stream did not return false on unknown filter (got " . var_export($decoded, true) . ")"; | ||
| } | ||
|
|
||
| // REQ-5: set_stream leaves _stream + Length untouched on unknown filter. | ||
| $lengthBefore = (int) ((string) $obj->get_value()['Length']); | ||
| ob_start(); | ||
| $obj->set_stream("new plaintext that should be rejected", false); | ||
| ob_end_clean(); | ||
| $lengthAfter = (int) ((string) $obj->get_value()['Length']); | ||
| $streamAfter = $obj->get_stream(true); | ||
|
|
||
| if ($streamAfter !== $originalCompressed) { | ||
| $failures[] = "REQ-5: set_stream mutated _stream on unknown filter (D4 fail-safe broken)"; | ||
| } | ||
| if ($lengthAfter !== $lengthBefore) { | ||
| $failures[] = "REQ-5: set_stream mutated Length on unknown filter (got $lengthAfter, expected $lengthBefore)"; | ||
| } | ||
| } | ||
|
|
||
| /* ---------------------------------------------------------------- * | ||
| * Report | ||
| * ---------------------------------------------------------------- */ | ||
| echo "PoC filter chain dispatch — round-trip verification\n"; | ||
| echo " spec: openspec/changes/feat-filter-chain-dispatch/specs/filter-chain-dispatch/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"; | ||
|
WilcoLouwerse marked this conversation as resolved.
|
||
| foreach ($failures as $msg) { | ||
| echo " - $msg\n"; | ||
| } | ||
| exit(1); | ||
|
WilcoLouwerse marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.