Skip to content

feat(tj-flat): TJ kerning-array flattening with 4-shape splicer (PR #07, stacked on #8)#9

Merged
rjzondervan merged 4 commits into
feat/tounicode-cmapfrom
feat/tj-flattening
May 28, 2026
Merged

feat(tj-flat): TJ kerning-array flattening with 4-shape splicer (PR #07, stacked on #8)#9
rjzondervan merged 4 commits into
feat/tounicode-cmapfrom
feat/tj-flattening

Conversation

@rjzondervan

Copy link
Copy Markdown
Member

Summary

Adds TJ operator support to the text-space matcher from #8. Needles spanning TJ fragment boundaries now match — covers the Word-emitted character-level-kerning pattern ([(J) -2 (a) -1 (n) ...] TJ) that's typical for Dutch government Woo documents.

Stacked on #8 feat/tounicode-cmap.

Splice algorithm (D2)

Four output shapes selected by match position:

Match position Output
Full TJ matched (placeholder) Tj
Prefix matched (placeholder) Tj [<remaining>] TJ
Suffix matched [<leading>] TJ (placeholder) Tj
Middle matched [<leading>] TJ (placeholder) Tj [<trailing>] TJ

Kerning OUTSIDE the match span is preserved exactly (visual appearance of surrounding text unchanged). Kerning INSIDE the match is discarded — micro-kerning was character-specific to the original text and doesn't apply to the synthetic placeholder. Trailing/leading kerning in the surrounding arrays is trimmed to avoid degenerate [... -5] shapes.

What landed (695 +, 60 −)

Helper (on PDFDoc) Purpose
processTjArray Main TJ handler — locates [...] operand, parses entries, matches needles, returns splice tuple
parseTjArrayContent Walks inner bytes; emits text (bytes + shape) and kern (numeric token verbatim) entries
renderTjArray / renderTjFragment Re-emit entries to PDF syntax preserving original shapes

Spec contract

Pinned by openspec/changes/feat-tj-flattening/:

  • REQ-1 — Tokeniser emits per-fragment entries
  • REQ-2 — Matching across TJ fragment boundaries succeeds
  • REQ-3 — Splicer produces correct shape per match position (full / prefix / suffix / middle)
  • REQ-4 — CID-boundary alignment honoured; misaligned matches → cid_split_mismatch
  • REQ-5 — Diagnostic surface gains tj_arrays_modified: int

Test plan

  • php examples/poc-tj-flattening.php — synthesises minimal PDFs with character-level TJ kerning; verifies all 4 splice shapes end-to-end
  • All 7 prior gates remain green (PoC + dispatcher + 4 filters + CMap)
  • php -l clean on touched files
  • No new composer dependencies; PHP 7.4 compat preserved

Dependencies + ordering

Out of scope

  • Helvetica fallback when active font can't encode placeholder → PR feat(cmap): ToUnicode CMap + text-space matching (PR #06, stacked on #7) #8
  • Re-emitting kerning around the placeholder (e.g. to "tighten" the spacing post-redaction) — out of scope; readers handle the placeholder's natural metrics
  • TJ within TJ (nested array) — PDF 1.7 §9.4.3 forbids; parser bails to fall-through

… PR #7)

Adds TJ operator support to the text-space matcher from PR #6.
Needles spanning TJ fragment boundaries now match — covers the
Word-emitted character-level-kerning shape that's typical for Dutch
government Woo documents.

New PDFDoc helpers (~250 LOC):
  - processTjArray: main handler when the walker hits TJ. Locates
    the [...] operand, parses entries, matches needles, returns the
    splice tuple (preChunk, replacement, opEndOffset).
  - parseTjArrayContent: walks inner bytes producing entries of two
    kinds — 'text' (bytes + 'literal'|'hex' shape) and 'kern'
    (original numeric token preserved verbatim).
  - renderTjArray / renderTjFragment: emit entries back to PDF
    syntax preserving the original shapes.

Splice algorithm per D2 — four output shapes by match position:

  Full TJ matched   → (placeholder) Tj
  Prefix matched    → (placeholder) Tj [<remaining>] TJ
  Suffix matched    → [<leading>] TJ (placeholder) Tj
  Middle matched    → [<leading>] TJ (placeholder) Tj [<trailing>] TJ

Kerning entries OUTSIDE the match span are preserved (visual
appearance of surrounding text unchanged); kerning entries INSIDE
the match span are discarded (the micro-kerning was character-
specific and doesn't apply to the synthetic placeholder). Trailing
kerning in the leading array (and leading kerning in the trailing
array) are trimmed to avoid degenerate [... -5] shapes that PDF
readers would tolerate but look ugly.

CID-boundary alignment: matches whose start or end lands in a
fragment's interior (e.g. one fragment resolves to a 'fi' ligature
and the needle ends mid-ligature) are skipped and recorded as
cid_split_mismatch — same diagnostic key as the CID-interior case
in PR #6.

Diagnostic surface gained:
  - tj_arrays_modified: int (counter for matched TJ operators,
    separate from streams_modified)

Contract pinned by openspec/changes/feat-tj-flattening/ (proposal
+ design D1-D5 + spec REQ-1 through REQ-5 with MODIFIED text-
replacement capability + tasks).

Verification:
  - examples/poc-tj-flattening.php (new) — synthesises minimal PDFs
    with character-level TJ kerning and verifies all 4 splice shapes
    end-to-end. 5 REQs covered, 0 assertion failures.
  - All 7 prior gates remain green.

The stack (PoC + dispatcher + 4 filters + CMap + TJ) is now
production-ready except for the Helvetica fallback (PR #8) that
recovers from subset-font font_encoding_misses.

Closes openspec/changes/feat-tj-flattening/tasks.md 26/26.
Comment thread src/PDFDoc.php
Comment thread examples/poc-tj-flattening.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
@@ -875,24 +875,39 @@ private function replaceInContentStream($stream, array $substitutions, array $pa
// through the active font and run substitutions in text space.

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/PDFDoc.php:875; finding concerns src/PDFDoc.php:681.)

🟡 Concern — Operator spacing inconsistency

$replacement .= $this->renderTjArray($leadingEntries) . ' TJ '; uses single-space delimiter. The Tj path elsewhere may emit \n. Mixing single-space-glued operators in the middle of an otherwise newline-formatted stream produces oddly-formatted PDF source.

Suggested fix: Verify with a real Word fixture and compare line-break density.

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-tj-flattening.php:249; finding concerns examples/poc-tj-flattening.php:367.)

🟡 Concern — D2 contract under-tested

Middle-match test checks substr_count($result['decoded'], 'TJ') >= 2 but doesn't verify leading/trailing TJ arrays contain original -2 kerns, nor that no -2 leaked into placeholder region.

Suggested fix: Add assertion that decoded output contains -2 between leading characters AND does NOT contain -2 immediately before/after the placeholder Tj.

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-tj-flattening.php:249; finding concerns examples/poc-tj-flattening.php:378.)

🟡 Concern — Doesn't verify tj_arrays_modified == 0

tasks.md says 'verify ... still exits 0 with tj_arrays_modified == 0'. PoC merely shells out to poc-replace-text.php and checks $exit !== 0. Doesn't look at the new key.

Suggested fix: Read child PoC's diagnostic surface, or have child fail-loud when key is non-zero.

/* ---------------------------------------------------------------- *
* REQ-3 — Middle match: leading TJ + placeholder Tj + trailing TJ
* ---------------------------------------------------------------- */
{

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 — Hex-shape path unexercised

buildTjFixture emits only (literal) fragments. The <hex> branch in parseTjArrayContent plus renderTjFragment hex branch never exercised. Real Word output frequently uses hex strings for Identity-H CIDs.

Suggested fix: Add a second buildTjFixtureHex covering hex fragments.

Comment thread docs/upstream-prs/07-tj-flattening/design.md
Comment thread docs/upstream-prs/07-tj-flattening/design.md
Comment thread openspec/changes/feat-tj-flattening/tasks.md
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
@@ -875,24 +875,39 @@ private function replaceInContentStream($stream, array $substitutions, array $pa
// through the active font and run substitutions in text space.

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/PDFDoc.php:875; finding concerns src/PDFDoc.php:577.)

🟢 **Minor — Docblock could be clearer

Return null on unexpected byte causes outer caller to copy bytes from $pos to $next + 2. The local comment hints 'bail to fall-through' but doesn't make recovery semantics clear.

Suggested fix: Add explicit note in the docblock.

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-tj-flattening.php:249; finding concerns examples/poc-tj-flattening.php:375.)

🟢 Minor — Cross-platform portability

On Windows, PHP_BINARY = C:\\Program Files\\php\\php.exe, escapeshellcmd strips meta chars but won't quote the path.

Suggested fix: Use escapeshellarg for portability, or document POSIX-only constraint.

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-tj-flattening.php:249; finding concerns examples/poc-tj-flattening.php:263.)

🟢 Minor — Heuristic stream selection

Test loops over every object, finds first one with /Filter == /FlateDecode containing BT. Fixture only has one such stream. Future fixture with images that compress with BT would mis-target.

Suggested fix: Use deterministic selector (page's /Contents ref).

Comment thread src/PDFDoc.php
Comment thread src/PDFDoc.php
@@ -875,24 +875,39 @@ private function replaceInContentStream($stream, array $substitutions, array $pa
// through the active font and run substitutions in text space.

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/PDFDoc.php:875; finding concerns src/PDFDoc.php:584.)

🟢 Minor — No sanity cap on backward scan

If producer emits a ] somewhere weird with no preceding [, the loop scans all the way to byte 0. On a 10MB stream this is O(n).

Suggested fix: Cap at 4096 bytes — TJ arrays aren't megabytes long.

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

Solid splice implementation but has critical multi-match-in-same-TJ bug, missing REQ-4 CID-alignment test coverage despite tasks marked done, PHP notice risk on undeclared stats keys, dead-code variable, broken hex-string handling for odd-length / empty hex; documentation is triplicate-duplicated across three docs files.

Findings: 🔴 5 blockers · 🟡 14 concerns · 🟢 6 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/PDFDoc.php:875 — Multi-match in a single TJ array silently lost (only first needle/occurrence processed)
  • examples/poc-tj-flattening.php:249 — REQ-4 (CID-boundary alignment) marked done in tasks.md but has no assertion in the test gate
  • src/PDFDoc.php:875 — Undefined-index notice / silent zero on replacements_per_needle[$needle]++
  • src/PDFDoc.php:875 — Hex string with odd-length nibbles silently produces wrong bytes (PDF 1.7 §7.3.4.3 violation)
  • src/PDFDoc.php:875 — Numeric tokenizer accepts scientific notation (1e5) — non-PDF-spec leniency

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

…j-flattening

# Conflicts:
#	openspec/changes/feat-tj-flattening/tasks.md
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.

@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 (Strict) — 3 new findings + 8 carry-overs

Comment thread openspec/changes/feat-tj-flattening/specs/tj-flattening/spec.md
Comment thread openspec/changes/feat-tj-flattening/tasks.md Outdated
Comment thread openspec/changes/feat-tj-flattening/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.

Re-review (Strict) — REQUEST_CHANGES

Fix commit 43f6dbd substantively addressed 17 of 25 prior findings (all 5 🔴 blockers + 9 of 14 🟡 + 3 of 6 🟢). I resolved those threads. Verdict held by 1 new 🔴 + 2 new 🟡 + 4 still-open prior 🟡.

🔴 New blocker (1)

🟡 New concerns (2)

🟡 Still-open carry-overs (4)

🟢 Still-open minors (4)

✅ Resolved this round (17 threads — all confirmed against head SHA)

Prior finding How addressed
🔴 Multi-match TJ Outer while-loop at src/PDFDoc.php:1166-1280; spec REQ-006 added
🔴 Phantom REQ-4 (task 5.5) Reverted to [ ] with deferral note for PR #10
🔴 Missing isset check Explicit guard at lines 1270-1273
🔴 Odd-length hex silently dropped Pad-with-zero at lines 1409-1411; explicit false check
🔴 Numeric tokenizer scientific notation Restricted to -+.0-9 (lines 1418, 1428)
🟡 Dead $matchedOnce Removed by rewrite
🟡 Comments not as whitespace Now skipped per PDF 1.7 §7.2.4 (lines 1377-1382)
🟡 tj_arrays_modified contract fragile Pre-initialised at line 825
🟡 preg_match per-byte Replaced with strpos against 6-byte PDF whitespace set
🟡 Shape inheritance asymmetric Placeholder now always 'literal' (line 1252)
🟡 Triplicate Implementation Note Canonical pointers in all 3 docs files
🟡 Numbering scheme drift "Stacked on (#6)" line removed in triplicate sweep
🟡 Phantom-ticked task 5.4 Reverted to [ ] with deferral note
🟡 Defensive @ on hex2bin @ removed; explicit false check
🟡 Tf set inside q/Q leaks Acknowledged as deferred gap in fix-commit message
🟢 end()/reset() pointer-advancing New group-based render uses numeric indexing
🟢 Edge-case completeness note Was note-only; no fix needed

Please address the new findings + the still-open 🟡 carry-overs before requesting another review. Strict-mode holds open for any 🟡.

Blocker:
- spec.md REQ-005 (parser tightening) and REQ-006 (multi-match in
  same TJ) were added in the first-pass fix without scenarios under
  examples/ — violating the spec's "Every requirement below MUST be
  exercised by at least one scenario in the change's verification
  gate" clause.

  PoC now adds:
  REQ-005 — parseTjArrayContent invoked via Reflection (the
  contract is purely about token-level parsing, no full PDF needed):
    * Exponent notation `1e2` MUST cause parse-bail (return null)
    * Odd-length hex `<41B>` MUST decode to bytes 0x41 0xB0
    * `(A) % comment\n -2 (B)` MUST emit [text:A,kern:-2,text:B]
    * VT (0x0B) MUST cause parse-bail (not in §7.2.3 whitespace set)
    * All 6 spec-defined WS bytes between fragments MUST be skipped
  REQ-006 — full PDF round-trip via runReplace():
    * Two different needles in one TJ → both placeholders land,
      both per-needle counters hit 1, tj_arrays_modified == 1
    * Same needle twice in one TJ → 2 placeholders, counter = 2

Concerns:
- Task 5.3 phantom-tick (PoC shelled out to poc-replace-text.php
  and only checked $exit !== 0, never reading tj_arrays_modified)
  now actually enforced: poc-replace-text.php emits a
  `tj_arrays_modified: <N>` line on stdout, and the TJ PoC parses
  it from the child's captured output. Fails loud if the value is
  non-zero OR the key is missing entirely (diagnostic-surface drift).
- tasks.md previously had no entries tracking the shipped REQ-005
  parser-tightening or the REQ-006 multi-match loop (the strict-
  review fix code in parseTjArrayContent + processTjArray's outer
  while-loop). Added 2.6 (parser tightening) and 3.6 (multi-match
  loop) with explicit references to the scenarios that exercise
  them. Added 5.6 (REQ-005 scenarios) and 5.7 (REQ-006 scenarios).

Verification: PoC now runs 8 named scenarios (was 5); result line
updated to "all 6 REQs covered". All other PoC gates still green
including the child poc-replace-text.php which gained the new
diagnostic line.
// Emit the TJ-arrays counter so downstream verification gates can
// assert "Tj-only fixture leaves tj_arrays_modified == 0" without
// reaching into the doc surface twice.
echo " tj_arrays_modified: " . ($stats['tj_arrays_modified'] ?? 0) . "\n\n";

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 — ?? 0 fallback defeats the missing-key drift-detection branch

The ca1c2f5f commit message states: "Fails loud if the value is non-zero OR the key is missing entirely (diagnostic-surface drift)." This emit uses ($stats['tj_arrays_modified'] ?? 0), so the line is always present in the child PoC's stdout. The parent gate's missing-key branch (examples/poc-tj-flattening.php:383) therefore can't fire: if replace_text_in_document ever drops the pre-init at src/PDFDoc.php:825, the child still emits tj_arrays_modified: 0, the parent's [0-9]+ regex matches, and the drift goes undetected.

The Tj-only zero-check still works (this is the primary regression you want to catch), so this is non-blocking. Worth tightening as a follow-up so the secondary contract guard actually fires:

echo "  tj_arrays_modified:        " . (array_key_exists('tj_arrays_modified', $stats) ? $stats['tj_arrays_modified'] : 'MISSING') . "\n\n";

Then the parent's [0-9]+ regex falls through to the missing-key branch when the implementation forgets the pre-init.

* ---------------------------------------------------------------- */
{
// Concatenated text: "Jan Jansen meets Karel Karelsen here"
$pdf = buildTjFixture('', ''); // ignored; we build our own below

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 — Dead buildTjFixture('', '') call

This line's result is immediately overwritten on line 310. The // ignored; we build our own below comment suggests the author intended a different construction path here, but the line below just reuses buildTjFixture with non-empty args — there's no separate fixture builder. Drop this line (and both surrounding comments, since the next line's args are self-explanatory).

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

🟢 APPROVE — All three findings from my second-pass strict review are addressed in ca1c2f5f.

Resolved

  • 🔴 REQ-005 and REQ-006 added without scenarios → 6 new scenarios in examples/poc-tj-flattening.php: 4 parser-level via parseTjArrayContent reflection (exponent-bail on 1e2, odd-length hex <41B>\x41\xB0, %-comments treated as whitespace, VT (0x0B) bails + all 6 spec WS bytes skipped) and 2 full-PDF round-trips (two-needles-one-TJ + same-needle-twice).
  • 🟡 Task 5.3 phantom-tickexamples/poc-replace-text.php:85 now emits tj_arrays_modified: N; parent gate at examples/poc-tj-flattening.php:373 parses the child's stdout and fails on non-zero or missing.
  • 🟡 tasks.md missing REQ-005/006 entries → tasks 2.6 (parser tightening), 3.6 (multi-match loop), 5.6 (REQ-005 scenarios), 5.7 (REQ-006 scenarios) added with explicit REQ references.

Follow-ups (non-blocking, posted inline)

Still open from the first-pass review (4 🟡 + 4 🟢): author deferrals — not raising the bar in this re-review.

@rjzondervan rjzondervan merged commit 4b0aefb into feat/tounicode-cmap May 28, 2026
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(api): Helvetica fallback + parameter validation + stable diagnostic surface (PR #8, stacked on #9) — COMPLETES SERIES
rjzondervan added a commit that referenced this pull request May 28, 2026
feat(tj): TJ-array flattening + multi-match in same TJ (SAPP PR #7)
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