Skip to content

chore(openspec): scaffold OpenSpec workflow for the text-replacement upstream-PR series#2

Merged
rjzondervan merged 2 commits into
work/text-replacementfrom
chore/openspec-scaffold
May 28, 2026
Merged

chore(openspec): scaffold OpenSpec workflow for the text-replacement upstream-PR series#2
rjzondervan merged 2 commits into
work/text-replacementfrom
chore/openspec-scaffold

Conversation

@rjzondervan

Copy link
Copy Markdown
Member

Summary

Adds the OpenSpec scaffolding (openspec/{config.yaml, README.md, changes/, specs/, architecture/}) plus the 8 OpenSpec changes that contract the text-replacement feature on work/text-replacement. Each change maps 1:1 to an existing upstream-PR draft folder under docs/upstream-prs/.

Changes scaffolded

OpenSpec change Upstream-PR draft PDF 1.7 ref
feat-filter-chain-dispatch docs/upstream-prs/05-filter-chaining/ §7.4
feat-asciihex-decode docs/upstream-prs/01-asciihex-decode/ §7.4.2
feat-runlength-decode docs/upstream-prs/02-runlength-decode/ §7.4.5
feat-ascii85-decode docs/upstream-prs/03-ascii85-decode/ §7.4.3
feat-lzw-decode docs/upstream-prs/04-lzw-decode/ §7.4.4
feat-tounicode-cmap docs/upstream-prs/06-tounicode-cmap/ §9.10
feat-tj-flattening docs/upstream-prs/07-tj-flattening/ §9.4
feat-text-replacement-api docs/upstream-prs/08-text-replacement-api/ n/a (new public API)

Each change carries the full OpenSpec artifact set (proposal.md, design.md, specs//spec.md, tasks.md), generated via /opsx:ff and verified with openspec status --change <name> (all report 4/4 artifacts complete).

Why now

We're shipping in this fork first (OpenRegister consumes via a composer VCS repository pointing at work/text-replacement), then contributing back to dealfonso/sapp after the feature stabilises. OpenSpec gives us:

  • A formal contract per upstream PR before the implementation lands
  • A place to record implementation findings worth surfacing in upstream review
  • Spec-driven test plans pinned to PDF 1.7 spec section references

No source-code changes

This PR is pure scaffolding. The 8 changes get implemented as separate feature branches per their tasks.md checklists, each PR'd into work/text-replacement in dependency order (#5 first, then #1-#4 in any order, then #6, then #7, then #8).

Test plan

  • openspec status --change <name> reports 4/4 complete for all 8 changes
  • config.yaml is PHP-library-tuned (no Nextcloud-app rules)
  • No src/ or examples/ modifications in this PR

…upstream-PR series

Adds openspec/{config.yaml, README.md, changes/, specs/, architecture/}
plus the 8 OpenSpec changes that contract the text-replacement feature
on the work/text-replacement integration branch. Each change maps 1:1
to one of the upstream-PR draft folders under docs/upstream-prs/:

  - feat-filter-chain-dispatch  → docs/upstream-prs/05-filter-chaining/
  - feat-asciihex-decode        → docs/upstream-prs/01-asciihex-decode/
  - feat-runlength-decode       → docs/upstream-prs/02-runlength-decode/
  - feat-ascii85-decode         → docs/upstream-prs/03-ascii85-decode/
  - feat-lzw-decode             → docs/upstream-prs/04-lzw-decode/
  - feat-tounicode-cmap         → docs/upstream-prs/06-tounicode-cmap/
  - feat-tj-flattening          → docs/upstream-prs/07-tj-flattening/
  - feat-text-replacement-api   → docs/upstream-prs/08-text-replacement-api/

Each change carries the full OpenSpec artifact set — proposal.md,
design.md, specs/<capability>/spec.md, tasks.md — generated via
/opsx:ff and verified with `openspec status --change <name>` (all
report 4/4 artifacts complete).

The config.yaml is tuned for a PHP library: PSR-12 + PHP >=7.4 +
snake_case + zero-deps + no Nextcloud-app rules. Rules under
`proposal:`, `specs:`, `design:`, `tasks:` reflect the fork-and-give-back
ordering: we ship in this fork first (OpenRegister consumes via a
composer VCS repository pointing at work/text-replacement), then
create issues + PRs to dealfonso/sapp once the feature stabilises in
the consumer.

No source-code changes in this commit — pure scaffolding. The 8
changes get implemented as separate feature branches per the tasks.md
checklists, each PR'd into work/text-replacement.

#### Scenario: Aligned zero run uses `z`

- **WHEN** the input is `\x00\x00\x00\x00` (4 zero bytes)

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 — Scenarios miss GIVEN clause across all 8 changes

The writing-specs.md guide prescribes - GIVEN <precondition> / - WHEN <action> / - THEN <expected> / - AND <additional> as the canonical Gherkin format. All 111 scenarios across the 8 spec.md files use - **WHEN** ... / - **THEN** ... / - **AND** ... only — there is not a single GIVEN bullet anywhere. The preconditions get folded into the WHEN line (e.g. **WHEN** the input is X).

Impact: Every scenario in this scaffold is non-canonical relative to the org's own guide. Downstream /opsx-apply and /opsx-verify tooling that parses scenarios for Gherkin keywords may produce inconsistent diagnostic output.

Suggested fix: Run a mechanical pass over all 8 spec.md files, splitting each **WHEN** the input is X into GIVEN <state> + WHEN <action>. Drop the markdown bold from the keywords.

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 fix — leaving open as a follow-up.

The mechanical GIVEN/WHEN/THEN pass landed in 5 of 10 spec files. The other 5 still go straight - WHEN … - THEN … for every scenario:

  • openspec/changes/feat-filter-chain-dispatch/specs/filter-chain-dispatch/spec.md (12 scenarios — 0 GIVEN)
  • openspec/changes/feat-tounicode-cmap/specs/tounicode-cmap-resolution/spec.md (12 scenarios — 0 GIVEN)
  • openspec/changes/feat-tounicode-cmap/specs/text-replacement/spec.md (9 scenarios — 0 GIVEN)
  • openspec/changes/feat-text-replacement-api/specs/text-replacement/spec.md (8 scenarios — 0 GIVEN)
  • openspec/changes/feat-text-replacement-api/specs/subset-font-fallback/spec.md (8 scenarios — 0 GIVEN)

Also: in the 5 partially-fixed files, scenarios under ## MODIFIED Requirements still skip the GIVEN line (e.g. feat-asciihex-decode/specs/asciihex-decode-filter/spec.md REQ-001).

Fine to defer to a follow-up — approving on the strength of the other fixes.

The encoder MUST emit 5 ASCII chars per 4 input bytes, use the `z` shortcut for aligned 4-zero-byte groups, terminate with `~>`, and handle trailing partial groups by padding with `u` and emitting only the relevant prefix.

#### Scenario: Basic encode

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 — Requirement headings lack REQ-NNN numbering

The writing-specs.md guide mandates ### REQ-{NNN}: <Name> numbering inside spec files so that tasks.md and PR titles can reference them as {capability}#REQ-NNN. All 49 requirements across the 8 spec.md files use descriptive ### Requirement: <SHALL-statement> headings.

Impact: tasks.md entries cannot use the canonical cross-reference shorthand. The current ### Requirement: ... heading collapses to a fragile GitHub anchor — renaming the requirement breaks every link.

Suggested fix: Renumber all requirements as ### REQ-001: <Name>, ### REQ-002: <Name> etc. within each spec.md.

Comment thread openspec/README.md
integration branch of `ConductionNL/sapp`. Each change corresponds to
one PR that we eventually intend to contribute back upstream to
`dealfonso/sapp` once the feature stabilises in our primary consumer
(OpenRegister's PDF anonymisation pipeline).

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 — README slash-command syntax doesn't match the loaded skills

The openspec/README.md Workflow section writes commands as /opsx:ff, /opsx:continue, /opsx:apply, /opsx:verify, /opsx:archive — the colon-separated form. The currently-loaded skills use the hyphen form: opsx-ff, opsx-continue, opsx-apply, opsx-verify, opsx-archive.

Impact: A developer reading the README and typing /opsx:ff will hit a 'skill not found' error.

Suggested fix: Rewrite the five workflow bullets as /opsx-ff, /opsx-continue, /opsx-apply, /opsx-verify, /opsx-archive.

- Wire the filter name `/ASCIIHexDecode` into the chain dispatcher introduced by `feat-filter-chain-dispatch` (PR `#05`).
- Spec-faithful behaviour per §7.4.2:
- Decode: accept `0..9A..Fa..f`, ignore whitespace + EOL anywhere, terminate at `>` (EOD marker), tolerate odd-length input by treating the trailing hex digit as if followed by `0`.
- Encode: emit uppercase hex pairs with a trailing `>` EOD marker; line-wrap at 80 columns (Adobe's de-facto convention; not normative but matches reader expectations).

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 — PR \#1`` style conflates upstream-PR-draft folder index with ConductionNL/sapp PR number

The proposal/design/tasks across all 8 changes refer to dependencies as PR \#1`, PR `#5`, PR `#6`, PR `#7`, PR `#8`. These numbers refer to the docs/upstream-prs/NN-/folder index, NOT to the actual GitHub PR numbering. A reviewer encounteringPR `#1`will reasonably look for it as#1` and not find it.

Impact: Ambiguous cross-references throughout 24 documents.

Suggested fix: Either (a) qualify all references as upstream-PR #01, or (b) drop the numeric form entirely and reference by change name (feat-filter-chain-dispatch).

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 fix — leaving open as a follow-up.

Singular PR `#NN` form fixed everywhere ✅. Plural range form PRs `#01`–`#04` (with the s and the en-dash range) wasn't caught by the mechanical pass — 8 occurrences remain, including in the README section that explains the disambiguation rule itself:

Fine to fold into the same follow-up pass as C1.

@@ -0,0 +1,91 @@
## ADDED Requirements

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 — tj-array-flattening capability slug breaks the change↔capability naming pattern

For 7 of the 8 changes, the change folder name and the capability slug share an obvious stem (feat-asciihex-decodeasciihex-decode-filter, etc.). But feat-tj-flattening produces a capability named tj-array-flattening.

Impact: Anyone searching for a tj-flattening spec under openspec/specs/ after archiving will not find it.

Suggested fix: Rename the spec folder to openspec/changes/feat-tj-flattening/specs/tj-flattening/spec.md and update the capability slug in feat-tj-flattening/proposal.md.


- **WHEN** the input is `random_bytes(1024)`
- **THEN** the round-trip MUST equal the input byte-for-byte

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 — Vague THEN clause in 'Trailing partial group' scenario

Scenario says **WHEN** the input is \87cURDZ~>`/THEN the decoder MUST return the appropriate 5-byte string per §7.4.3 padding rules`. The THEN clause does not state what the expected 5-byte output IS — it defers to the spec. This is exactly the 'too vague' anti-pattern.

Suggested fix: Compute the expected output byte-by-byte and write it explicitly, e.g. **THEN** the decoder MUST return the 5-byte string \``.

@@ -0,0 +1,55 @@
## Context

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 — Migration Plan section asymmetry

The design.md files break into two camps: WITH ## Migration Plan (feat-asciihex-decode, feat-filter-chain-dispatch, feat-text-replacement-api, feat-tounicode-cmap) and WITHOUT (feat-ascii85-decode, feat-lzw-decode, feat-runlength-decode, feat-tj-flattening). All eight changes are 'strictly additive', so if Migration Plan is needed for one it is needed for all.

Suggested fix: Add ## Migration Plan to the four missing files (the content can be 'No migration needed — strictly additive').

Comment thread openspec/README.md Outdated
- `/opsx:continue` — Resume work on an in-progress change
- `/opsx:apply` — Drive the implementation from `tasks.md`
- `/opsx:verify` — Verify implementation matches the spec
- `/opsx:archive` — Move a completed change into `specs/` No newline at end of file

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 — Missing POSIX-mandated trailing newline

The diff shows \ No newline at end of file on 12 files: openspec/README.md, several proposal.md, design.md, spec.md files, and openspec/config.yaml. Other files in the same PR DO have trailing newlines — internal inconsistency.

Suggested fix: Add trailing newline to each affected file.

@@ -0,0 +1,82 @@
## ADDED Requirements

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 — Delta specs omit the mandatory Status/Scope/OpenSpec changes header

The writing-specs.md guide prescribes that every spec file opens with **Status**, **Scope**, **OpenSpec changes**, ## Purpose, etc. headers. None of the 8 spec deltas include this. The guide does not explicitly say delta specs reuse the header, but the same content has to materialise when /opsx-archive produces canonical openspec/specs/<cap>/spec.md.

Suggested fix: Either (a) add a brief ## Purpose paragraph at the top of each delta spec.md, or (b) leave a TODO noting the archiver must add Purpose/Non-Functional/Acceptance/Notes when promoting.

@@ -0,0 +1,2 @@
schema: spec-driven

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 — Bytewise-identical 2-line .openspec.yaml files

Every change-folder ships a 2-line .openspec.yaml with the same schema and created. No per-change identifying field.

Suggested fix: Either drop the file (folder name is the identity) or extend the schema to include name:, status:, capabilities:.

Comment thread openspec/README.md
| `feat-ascii85-decode` | `docs/upstream-prs/03-ascii85-decode/` | §7.4.3 |
| `feat-lzw-decode` | `docs/upstream-prs/04-lzw-decode/` | §7.4.4 |
| `feat-runlength-decode` | `docs/upstream-prs/02-runlength-decode/` | §7.4.5 |
| `feat-tounicode-cmap` | `docs/upstream-prs/06-tounicode-cmap/` | §9.10 (ToUnicode CMaps) |

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 — Mapping table presents the 8 changes out of dependency order

The table lists changes in dependency order (foundation first), but the right-most column (upstream-PR index) is sorted as 05, 01, 03, 04, 02, 06, 07, 08 — jumbled.

Suggested fix: Add a one-line note above the table explaining rows are in dependency order, or add an explicit 'Depends on' column.

Comment thread openspec/README.md Outdated

The `docs/upstream-prs/01-asciihex-decode/` ... `08-text-replacement-api/`
folders are the actual artefacts we'll paste into the upstream PRs.
The `openspec/changes/` folder is where the work is planned and

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 — Forward references to a not-yet-existing folder structure

The README and every change's proposal.md/tasks.md reference docs/upstream-prs/NN-<slug>/. None of these folders are created in this PR (created lazily by later PRs).

Suggested fix: Either include empty docs/upstream-prs/NN-<slug>/.gitkeep placeholders, or add a one-line note clarifying lazy creation.

After PRs `#01`–`#07` land, the text-replacement machinery handles all four PDF filters (FlateDecode + ASCIIHex + ASCII85 + RunLength + LZW), filter chains, Identity-H + ToUnicode CMap resolution, and TJ kerning-array flattening. What's still missing for the production use case is **the Helvetica fallback when a subset font can't encode the placeholder**. This is the final blocker for the >95% Woo case.

Word emits subset fonts containing only the glyphs the document actually uses. A document about "Jan Jansen" produced by a typical Dutch government template won't have `[`, `:`, or digits in the subset's font program. Our placeholder `[PERSON: 7]` then triggers `font_encoding_misses` and the substitution is skipped — usable output but with the wrong-but-not-corrupt behaviour of leaving the name in place.

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 — Dependency chain prose vs Depends-on line drift

feat-text-replacement-api/proposal.md lists Depends on: feat-tounicode-cmap, feat-tj-flattening — accurate for direct dependencies. But the prose says 'After PRs #01#07 land' — implicitly requires all four filter-decoder changes. Those four are NOT hard deps.

Suggested fix: Tighten the prose to disambiguate direct dependencies vs production-rollout dependencies.

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 applies — fine to defer.

The prose After PRs \#1`–`#7` landstill implies a wider dependency surface than the explicitDepends on: feat-tounicode-cmap, feat-tj-flattening. Adjacent to C4's residual PRs `#NN`–`#NN`` cleanup — a single touch-up will resolve both.

Comment thread openspec/config.yaml
@@ -0,0 +1,49 @@
schema: spec-driven

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 — Duplication between config.yaml context: and README

The context: field is ~22 lines of detailed project context that duplicates the README's 'Why an OpenSpec scaffold inside a PHP library?' section verbatim. Two slightly-different prose blocks now have to be kept in sync.

Suggested fix: Strip context: to a 3–4 line summary and link to openspec/README.md for the full prose.

@@ -0,0 +1,29 @@
## Why

`RunLengthDecode` is the PDF 1.7 §7.4.5 filter used to compress streams with simple run-length encoding — primarily monochrome image data, but also occasionally seen on small content streams emitted by older toolchains. Upstream sapp doesn't implement it; without it, any chain containing `/RunLengthDecode` falls through to the no-op path and the text-replacement pipeline produces corrupted output.

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 — Three different framings for the same chain-fallthrough behaviour

feat-runlength-decode says 'corrupted output'; feat-asciihex-decode says 'malformed output'; feat-ascii85-decode/feat-lzw-decode are silent. Three different framings for the same behaviour.

Suggested fix: Pick one phrasing and use it consistently across all four filter-decoder proposals.

- Public API on `PDFDoc` stays the same shape (`replaceTextInDocument(array $substitutions): array`); the diagnostic surface gains `font_encoding_misses: array<int oid, array<string needle, string font_name>>`.

## Capabilities

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 — Inconsistent diagnostic key name

feat-tounicode-cmap/proposal.md mentions a font-cannot-encode diagnostic. Every other reference across the PR uses font_encoding_misses (an array key). Same thing, two names.

Suggested fix: Replace font-cannot-encode with the canonical key name font_encoding_misses.

@@ -0,0 +1,85 @@
## ADDED Requirements

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 — Missing Non-Functional / Acceptance Criteria / Notes sections

The writing-specs.md guide marks ## Non-Functional Requirements, ## Acceptance Criteria, ## Notes as 'Required: Yes' for every spec file. None of the 8 delta specs contain these sections.

Suggested fix: Add stub sections to each delta spec.md so the archive step has structure to populate.

@WilcoLouwerse

Copy link
Copy Markdown

(Originally anchored to openspec/changes/.gitkeep:1 — file is not in the diff, posted as top-level comment.)

🟢 Minor — Three .gitkeep files; one is redundant

openspec/architecture/.gitkeep and openspec/specs/.gitkeep keep otherwise-empty dirs visible to git — fine. But openspec/changes/.gitkeep is dead weight because 8 change folders make the dir non-empty.

Suggested fix: Drop openspec/changes/.gitkeep.

@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)

OpenSpec scaffold is internally consistent and well-organized as a docs-only PR but diverges from the Conduction writing-specs.md authoring conventions in several systematic ways (no GIVEN clauses, no REQ-NNN numbering, bold keyword styling) and contains several smaller drift issues (PR-number conflation, missing trailing newlines, slash-command syntax mismatch with current skill names, design-section asymmetry between changes, and one vague scenario).

Findings: 🔴 0 blockers · 🟡 9 concerns · 🟢 9 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.

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

Addresses all 9 concerns + 9 minors on PR #2 (chore/openspec-scaffold).

🟡 Concerns
  - GIVEN clauses added to scenarios via a mechanical pass: each
    `**WHEN** the input is X` split into `GIVEN <state>` + `WHEN
    <call> is invoked`. Markdown bold dropped from all GIVEN/WHEN/
    THEN/AND keywords (writing-specs.md canonical form). [10 specs]
  - REQ-NNN numbering added to all 49 requirements across 10 spec.md
    files. Each `## ADDED Requirements` and `## MODIFIED Requirements`
    section starts its counter at REQ-001 to keep delta-merge merges
    clean at archive time. [10 specs]
  - Status / Scope / OpenSpec changes / Purpose / Non-Functional /
    Acceptance Criteria / Notes leader block added to every spec
    delta so the archiver has consistent structure to promote into
    the canonical openspec/specs/ tree. [10 specs]
  - README slash-command syntax fixed: `/opsx:ff` etc → `/opsx-ff`
    et al. to match the loaded skill names.
  - `tj-array-flattening` capability slug renamed to `tj-flattening`
    so it matches the change↔capability stem of all other entries.
    Folder renamed via git mv; proposal.md updated.
  - Vague THEN clause in the ASCII85 "Trailing partial group"
    scenario replaced with concrete byte-level output ("Hello",
    concatenation 0x48 0x65 0x6C 0x6C 0x6F).
  - `## Migration Plan` section added to the four design.md files
    that were missing it (ascii85, lzw, runlength, tj). Stub content
    notes the strictly-additive nature.
  - `PR \`#NN\`` cross-references throughout proposal/design/tasks
    rewritten as `upstream-PR #NN` so they don't collide with the
    ConductionNL/sapp GitHub PR numbering.
  - feat-tounicode-cmap/proposal.md: `font-cannot-encode` diagnostic
    name renamed to the canonical `font_encoding_misses` array key.

🟢 Minors
  - Missing trailing newlines added on 11 files (README, config.yaml,
    proposals, designs, specs).
  - README mapping table now annotates rows as "(dependency order)"
    with a note explaining the order vs the upstream-PR draft folder
    index.
  - README explicitly notes that `docs/upstream-prs/NN-<slug>/`
    folders are created lazily by the implementation PRs and not
    by this scaffold change.

Deferred to follow-up (true minors per Wilco):
  - .openspec.yaml schema extension / drop
  - config.yaml `context:` ↔ README duplication

All 10 changes report 4/4 artifacts complete via
`openspec status --change <name>`.
@WilcoLouwerse

Copy link
Copy Markdown

🟢 Still applies, fine to defer — Re: my earlier top-level comment on openspec/changes/.gitkeep.

The file is still in the tree at 0 bytes despite 8 sibling change subdirs already keeping the folder visible. Same recommendation: drop openspec/changes/.gitkeep (and optionally openspec/specs/.gitkeep once the first capability is archived). Not a blocker for this PR.

@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 (APPROVE)

Substantial work in 1f1cafc — 13 of 16 prior concerns fully addressed; 2 explicitly deferred per the commit message; 2 partial fixes + 2 still-untouched minors documented as follow-ups in the inline thread replies.

Fully resolved: REQ-NNN numbering (C2), slash-command syntax (C3), tj-flattening rename (C5), concrete ASCII85 THEN (C6), Migration Plan symmetry across all 8 designs (C7), trailing newlines (C8), Status/Scope/Purpose/NFR/AC/Notes leader block on all 10 specs (C9 + M8), mapping-table dependency-order annotation (M2), README lazy-folder note (M3), chain-fallthrough framing standardization (M6), font_encoding_misses rename (M7), and the two deferrals accepted (M1, M5).

Partial / still-applies (one follow-up touch-up will close them all):

None of these gate the scaffold. Approving.

(Note: I don't have resolveReviewThread permission on this repo, so the 14 fully-fixed threads stay in GitHub's "Unresolved" filter — author or maintainer can flip them. work/text-replacement is not protected by required_review_thread_resolution, so unresolved threads do not block merge.)

rjzondervan added a commit that referenced this pull request May 28, 2026
Adds two new commits on top of the existing PR #3 branch:

  1. The PR #1 fix cherry-picked: PoC's PDFDoc::replaceTextInDocument
     rename to snake_case + add_object write-back + escape-contract
     docblock + streams_scanned semantics + Phase-2 exhaustive sweep
     in the verify gate + namespace/named-arg fixes in
     poc-make-fixture.php. Same content as the corresponding PR #1
     fix; arrives here because PR #3 has a cherry-pick of the PoC
     and the PR #1 review's blockers apply to that copy too.

  2. This commit: PR #3-specific fixes from Wilco's first-pass
     strict review.

🔴 PR #3 Blocker
  - PHP 7.4 named-args in poc-make-fixture.php — addressed by
    commit (1) above (the cherry-picked PR #1 fix replaces named
    args with positional and pins gzcompress level to 6).

🟡 PR #3 Concerns
  - get_stream(false) on chain failure now returns `false` (matching
    upstream's pre-refactor `return p_error(...)` semantics) rather
    than the raw $this->_stream. Callers using the `if ($decoded ===
    false) continue;` idiom get the skip behaviour they did before
    the dispatcher refactor. spec.md REQ-5 scenario updated to match.
  - build_flate_params /Columns default fixed from 0 → 1 per
    PDF 1.7 §7.4.4.3 Table 8. Hidden today because Predictor=1
    short-circuits; would have bitten the moment a PNG-predicted
    stream landed without explicit /Columns.
  - REQ-2 (encode-decode round-trip lossless) spec scenario
    tightened to "Predictor absent or = 1" — round-trip with a
    non-trivial PNG predictor is acknowledged as out of scope (no
    encode-side predictor support); future `feat-flate-predictor-encode`
    closes the gap.
  - design.md D1 wording fixed from "We add two `private` helpers"
    to "We add two `protected static` helpers", matching what
    shipped. Visibility choice justified (test-suite subclasses can
    stub for chain-ordering assertions; matches FlateDecode).
  - design.md D6 rewritten to reflect what the gate actually
    exercises (5 contract scenarios including the new REQ-3 smoke
    test) instead of the original "synthetic two-filter PDF"
    promise. Two-filter ordering is locked at spec layer but
    proved inductively by upstream-PRs #1-#4 each pairing the new
    codec with /FlateDecode.
  - examples/poc-filter-chain-roundtrip.php gained a REQ-3 block
    smoke-testing /DecodeParms positional routing — single-filter
    chain with explicit `/DecodeParms [<</Predictor 1 /Columns 4>>]`
    that round-trips cleanly.
  - docs/upstream-prs/05-filter-chaining/{proposal,design,tasks}.md
    no longer carry the duplicated 18-line "Implementation note"
    block. Each file now links to
    `openspec/changes/feat-filter-chain-dispatch/design.md` as the
    canonical artefact — single source of truth.
  - docs/upstream-prs/README.md now explicitly explains the two
    numbering schemes: directory `01..08` is the upstream-submission
    order; fork PR numbers are the local landing order. Foundation
    (`05`) lands first locally so dependent codecs (`01..04`) can
    attach. Cross-references use the `upstream-PR #NN` form.
  - replaceTextInDocument camelCase fixed — via the PR #1 fix
    cherry-pick (commit 1 above) which renames to snake_case
    replace_text_in_document.

🟢 Minors deferred
  - normalise_filter_chain dead defensive branch — the branch
    handles a `null` input that may or may not be reachable
    depending on PDFObject construction path; leaving as-is.

Verification: both poc-replace-text.php (PR #1 gate) and
poc-filter-chain-roundtrip.php (this PR's gate, now with the REQ-3
smoke test block) exit 0 after these two commits.

NOTE on the stacked-PR / no-force-push approach: this PR's commit
diff still references the OLD scaffold content from PR #2 (the
pre-rewrite spec.md / proposal.md / etc.). PR #2's mechanical
pass (REQ-NNN numbering + GIVEN clauses + Status/Purpose stub
headers) is shipped on `chore/openspec-scaffold` and will arrive
here naturally when this branch is rebased onto an updated
`work/text-replacement` after PR #2 merges. The spec amendments in
this commit will then merge with PR #2's rewrites — small expected
conflicts in spec.md's REQ-5 + REQ-2 scenarios, resolvable inline.
@rjzondervan rjzondervan merged commit af66a1e into work/text-replacement May 28, 2026
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}).
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(runlength): RunLengthDecode encode + decode + chain wiring (PR #2, stacked on #4)
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