# CERTIFICATE_EXTRACTION.md

**`cetc_new_certificate_body_alter()` (D7 `template.php` 275–566) → `ce_certificate` module.**
Specification produced 2026-08-25 from a complete read of the function.
**Status: specified. Implementation in progress. RUNTIME-UNVERIFIED.**

Governing rule — `CLAUDE.md` §21: *technically rewrite the existing certificate generation for
Drupal 10; observable certificate behaviour must remain equivalent; **do NOT** create an issued-
certificate entity, serial numbers, new issue dates, verification routes, revocation, or any
certificate lifecycle.*

---

## 0. ⚠️ Correction to earlier project notes

Two files (and my own earlier reports) stated this function carries the hard-coded **75** pass mark.
**It does not.** A full read found **no score, pass-mark, percentage, CE/non-CE or taxonomy
conditional anywhere in it**.

The 75 defect (**D-1**) lives **only** in `node--course.tpl.php`. Here, eligibility is decided by
the `quiz_certificate` **view** returning rows, or by the `completed` **flag**.

---

## 1. Contract

D7 implements `hook_certificate_body_alter()`, invoked from
`certificate/certificate.pages.inc:210` inside `theme_certificate_certificate()`:

```php
$field_items = field_get_items('node', $template, 'body');
$body = $field_items[0]['value'];
drupal_alter('certificate_body', $body, $account, $node, $template);
```

| Param | Meaning |
|---|---|
| `&$data` | **In:** raw, *un-token-replaced* `body` of the certificate template node. **Out:** replaced wholesale |
| `$account` | recipient user; only `->uid` and `->name` used. **On `certificate/pdf/preview` this is `global $user`** |
| `$node` | the **course** node. **CAN BE NULL** — the preview path passes `'node' => NULL` |
| `$template` | the certificate **template** node, aliased to `$certificate_node` |

`$data` is assigned exactly once, at the end: `$data = $data_new;`. `$data_new` starts `''` and is
built purely by `.=`. The **original `$data` is re-embedded** as a `<p>` inside the new markup.

### 🛑 Ordering fact the D10 rewrite must not break

**Token replacement happens AFTER this function returns**, in the caller, with
`clear = TRUE`, `sanitize = FALSE`, `callback = _certificate_sanitize_tokens`, types
`global`/`node`/`user`. So template-body tokens must **survive** into the output string and be
resolved downstream. This function performs **no** token replacement itself.

`check_markup()` is **commented out** in the caller, so the string is emitted raw.

---

## 2. Three-way branch — the whole business rule

| Branch | Condition (verbatim) | Completion date source |
|---|---|---|
| **A — quiz** | `if($result_count > 0)` | `date('F d, Y', $result[0]->quiz_node_results_time_end)` |
| **B — non-quiz course** | `elseif ($node->type == 'course' && $completed && empty($node->field_quiz))` | `$flag_content[$uid]->timestamp`, itself guarded by `in_array($uid, $uids)` |
| **C — refusal** | `else` | plain text, **no CSS, no markup** |

Branch C emits verbatim, typo included:

```
You did not attempt the quiz yet Or the course does not provide certifiacte.
```

**`certifiacte` is misspelled and `Or` is capitalised mid-sentence. Both preserved** — this is the
same class of preserved string contract as RB-99's "did not reached" typo.

---

## 3. 🛑 Hard dependency — the `quiz_certificate` VIEW

```php
$result = views_get_view_result('quiz_certificate', 'page', $nid, $uid);
```

Contextual filters `[nid, uid]`. Only `$result[0]->quiz_node_results_time_end` is consumed — a
Views alias over `quiz_node_results.time_end`. **The code blindly takes row `[0]`**, so the view's
own sort order decides which attempt dates the certificate.

> **The view exists ONLY in the D7 database.** `grep -rn "quiz_certificate"` finds the module
> (`quiz_certificate.module`, which contains only `quiz_certificate_access_certificate()`) and the
> themes — **no view definition in code**. It must be exported from D7 before branch A can be
> reproduced. Recorded as a blocked dependency; it does not block the rest of the extraction.

Also required: **Flag** (`completed`, hard-coded machine name), **Quiz** (`time_end`), **Views**.

---

## 4. Fields read

**From the certificate template node** — all guarded `isset(...['und']) && !empty(...[0]['value'])`,
defaulting to `""`:
`field_provider_approval_number` · `field_provider_license_number` · `field_course_number` ·
`field_total_hours` · `field_course_title` (entity reference → `node_load()` → `->title`)

**From the recipient user** (`user_load($uid)`):
`field_first_name` · `field_middle_name` · `field_last_name` · `field_licensee_number`

**From the course node:** `->nid`, `->type`, `->field_quiz`.

**No taxonomy term, no state field, no score is read anywhere.**

### Name assembly — the double space is real

```php
if(!empty($user_first_name)){
  $user_name = $user_first_name.' '.$user_middle_name.' '.$user_last_name;
}else{
  $user_name = $account->name;
}
```

With an empty middle name this yields **`"First  Last"` — two spaces**. That is observable output on
every certificate and is preserved. Falls back to the raw **username** when the first name is empty.

---

## 5. Output structure

Both emitting branches build an identical shell; only the date block differs.

```
<style>…</style>                                  ~228 KB, see §6
<div class="certificate-wrapper">
  <div style="text-align:center;">
    <table style="margin:auto; ">
      <tr><td class="logo-div"><div class="logo-bg"></div></td>
          <td class="logo-div-text"><h2>,Inc</h2></td></tr>
    </table>
    <p>Is Awarded To </p>
    <h2 class='name-of-user' >NAME</h2>
    [original $data as <p>]
    [Provider Approval Number: N ]
    [Provider License Number: N ]
    [course title / number — three variants, §7]
    [<p> Total Hours: H</p>]
    <div class='main_content_footer'><p>Date of Completion : <span …>DATE </span> </p>
    [<p>License Number : <span …>N</span></p></div>]
    </div>
    … president-info blocks (address left, signature right) …
```

**Whitespace is load-bearing.** Verified oddities, all preserved:

- `<p>Is Awarded To </p>` — trailing space
- `Provider Approval Number: N </p>` — space before `</p>`
- `<p> Total Hours:` — **leading** space inside `<p>`
- `Date of Completion : ` and `License Number : ` — space **before** the colon
- `<h2 class='name-of-user' >` — space before `>`
- Course-number separator is **U+2013 EN DASH**, not a hyphen: `" – "`
- `<h2>,Inc</h2>` — renders literally as `,Inc`; the company name is missing from the string
- Class `main_content_footer` uses **underscores**, unlike every other class

Fixed strings: `CE Online Training Courses, Inc.` · `228 West Ave` ·
`North Augusta, SC 29841` · `Jayson Lacy, President` · `ceonlinetraining.com`

---

## 6. Assets — ~228 KB of inline base64 per certificate

| Selector | Decoded | Dimensions | MD5 |
|---|---:|---|---|
| `body` background | 143,990 B | 945 × 700 | `b6d0ec334eac430f2133bfda670b2974` |
| `.sign-bg` | 2,693 B | 101 × 29 | `240855d6aee98e0a37fb0b5bb533da09` |
| `.logo-bg` | 16,273 B | 564 × 111 | `5abc38de2d3102f4c4b14ad5784a863e` |

Font: `url(/sites/all/themes/cetc_new/fonts/certificate-font/gv.ttf)` — **root-relative**, 43,300 B,
family `'Gv'`. `'Crimson Text', serif` is referenced by `*{}` but **never loaded**.

⚠️ **The font path must be re-pointed** to the D10 theme location, or PDFs lose the script font on
`.name-of-user`. This is a path fix, not a design change. The three PNGs are carried over byte-
identical (MD5s above are the acceptance check).

---

## 7. Course title / number — three variants

```php
if(!empty($title) || !empty($number)){
  if(!empty($title) && empty($number))       -> "<p><b>{title}</b></p>"
  elseif(empty($title) && !empty($number))   -> "<p><b>Course Number: {number}</b></p>"
  else                                        -> "<p><b>Course Number: {number} – {title}</b></p>"
}
```

---

## 8. Preserved defects

| # | Defect |
|---|---|
| **C-1** | **Unbalanced `<div>`s when licensee number is empty.** `</p></div>` is emitted *only* inside the licensee guard, so `.main_content_footer` closes early and `certificate-wrapper` is left open. Same defect in both branches |
| **C-2** | **Branch B double failure.** If `in_array($uid, $uids)` is FALSE, `.main_content_footer` is never opened, yet its `</p></div>` and the following `</div>` still fire — stray closing tags and a `</p>` with no open `<p>` |
| **C-3** | `certifiacte` typo + mid-sentence `Or` in branch C |
| **C-4** | `<h2>,Inc</h2>` — company name lost, renders as `,Inc` |
| **C-5** | Double space in `$user_name` when middle name is empty |
| **C-6** | `date()` not `format_date()` — **server** timezone, not site or user timezone |
| **C-7** | `$result[0]->quiz_node_results_time_end` unguarded; `count > 0` does not prove the property exists |
| **C-8** | `$data_old`, `global $base_url` — dead code |
| **C-9** | ~110 lines of copy-paste duplication between branches A and B |
| **C-10** | Raw unescaped output; `check_markup()` commented out in the caller |

**C-1 and C-2 produce malformed HTML that browsers and PDF engines silently repair — differently.**
Reproducing them exactly is required for output equivalence, but they are the highest-value
candidates for a **post-migration** fix. Not touched now.

### Robustness gaps that are NOT defects to reproduce

`$node` may be NULL on `certificate/pdf/preview`; `flag_get_flag()` is unchecked; `$course_node` is
not null-checked; `$acc_user` ternaries index `[0]['value']` unguarded. These are **crash paths**,
not observable behaviour. The D10 implementation guards them and, where D7 would have fatally
errored, produces the same result D7 produced when it did *not* error. Guarding a fatal is not a
behaviour change.

---

## 8b. ✅ IMPLEMENTED 2026-08-25 — runtime-unverified

```
web/modules/custom/ce_certificate/
├── ce_certificate.module            hook_certificate_body_alter()
├── ce_certificate.services.yml      4 services
├── src/CertificateBodyBuilder.php   the rewrite; C-1…C-9 preserved inline
├── src/CertificateAssets.php        CSS + 3 inline PNGs + re-pointed font
├── src/QuizCertificateResult.php    branch A — DEPENDENCY-BLOCKED, inert
└── assets/  certificate-background.png · signature.png · logo.png · fonts/gv.ttf
```

**Assets verified byte-identical to D7** — all four MD5s match §6 exactly:

| File | Bytes | MD5 |
|---|---:|---|
| `certificate-background.png` | 143,990 | `b6d0ec334eac430f2133bfda670b2974` |
| `signature.png` | 2,693 | `240855d6aee98e0a37fb0b5bb533da09` |
| `logo.png` | 16,273 | `5abc38de2d3102f4c4b14ad5784a863e` |
| `fonts/gv.ttf` | 43,300 | `2f55fb855419bb9d8dd22ec9fe7ea972` |

**Branch B is complete.** **Branch A is inert by design**: `QuizCertificateResult` returns `NULL`
until the view exists, so a quiz certificate falls through to B/C rather than showing a wrong date.

> **Deliberately NOT done:** substituting a hand-written `quiz_node_results` query for the view.
> D7 blindly takes row `[0]`, so **the view's own sort order decides which attempt dates the
> certificate**. A query with a different `ORDER BY` could date a certificate from a different
> attempt — a silent business-data change. It waits for the real definition.

### The one intentional difference from D7

The `@font-face` src is re-pointed from `/sites/all/themes/cetc_new/fonts/certificate-font/gv.ttf`
to this module's copy of the **same file**. A path fix, authorised by §6 — left unchanged,
`.name-of-user` would silently lose its script font in every PDF.

### C-9 handled at code level only

D7's ~110 lines of branch A/B duplication are consolidated into one `renderShell()`. The **emitted
string is identical either way** — which is what the acceptance test compares. No observable change.

---

## 9. D10 implementation shape

- `ce_certificate` service `CertificateBodyBuilder` — one public `build()`.
- An event subscriber / alter hook replaces `hook_certificate_body_alter()`.
- Branch A queries the migrated `quiz_certificate` view; **blocked until that view is exported**.
- Branch B uses the flag service.
- The three PNGs move to `ce_certificate/assets/` and are inlined identically at build time.
- Token replacement stays **downstream** — the builder must not resolve tokens.

**Acceptance:** render the same (user, course) pair on D7 and D10 and diff the HTML; compare the
generated PDFs visually (`CLAUDE.md` §32). The 108 historical certificate mappings (R-9) must
reconcile, with zero pointing at a missing node.

---

## 10. Branch A — the export script, and a trap closed (2026-08-25)

### 10a. `scripts/reconcile/d7_view_quiz_certificate.sh`

Read-only export of the `quiz_certificate` view. **All 7 SQL blocks verified SELECT-only.**

| § | What it answers |
|---|---|
| V-1 | does a `views_view` row exist, or is the view provided in code? |
| V-3 | ⭐ the `display_options` blob for **both** `default` and `page` — contains `sorts[]` |
| V-4 | the sort fragment, isolated (confirm against V-3; don't trust alone) |
| V-5 | 🛑 **blast radius** — user/quiz pairs with more than one evaluated attempt |
| V-6 | of those, how many have differing `time_end` — i.e. certificates whose printed date depends on the sort |
| V-7 | confirms `quiz_certificate.module` ships no `hook_views_default_views()` |

**Why the sort cannot be guessed.** D7 takes row `[0]` **blindly** — nothing picks a best attempt, so the view's `ORDER BY` alone decides which attempt dates the certificate. `time_end DESC` would look entirely reasonable and would print a **different date** on real certificates wherever a student retook a quiz.

⚠️ This sort is **not** necessarily the same as `QuizResultStorage::getBestResult()`'s `score DESC, time_end DESC`. Two separate D7 code paths; they may legitimately disagree. This system already carries two different pass marks in two places (`MIGRATION_PLUGINS.md` §5c) — assuming consistency here would be unfounded.

### 10b. 🛑 Trap closed — importing the view could have silently degraded certificates

`isBlocked()` originally checked only whether the **view entity existed**, so branch A would un-block itself the moment the view was imported — while `executeView()` was still a stub returning `NULL`.

In that window the certificate does **not** error. It falls through branch A, then branch B, and lands on **branch C**, printing:

> You did not attempt the quiz yet Or the course does not provide certifiacte.

…to students who **did** pass the quiz — a refusal message on a certificate they earned, with nothing in the logs anyone watches.

**Fix:** `EXECUTE_VIEW_IMPLEMENTED = FALSE`, checked **before** the view lookup. Both conditions must now hold, and the constant must be flipped **by hand in the same commit that implements `executeView()`**. Importing a view can no longer change what a certificate says.

`blockedReason()` reports which condition is failing, for the reconciliation log.

**Verified with comments stripped:** no `orderBy`, no `ORDER BY`, no query in executable code. The only `quiz_node_results` reference is the `RESULT_PROPERTY` constant recording D7's field alias. `executeView()` returns `NULL` and nothing else.

### 10c. Un-blocking checklist

1. Run `d7_view_quiz_certificate.sh`; paste the V-3 blob into §3.
2. Record V-5/V-6 — if V-6 is 0, the sort is provably immaterial for existing data and that is worth writing down.
3. Rebuild the view in D10 with the **exported** sort, both contextual filters `(nid, uid)` in order, and the `time_end` field.
4. Implement `executeView()`.
5. Flip `EXECUTE_VIEW_IMPLEMENTED` to `TRUE` **in the same commit**.
6. Golden-master: the same `(user, course)` pair must yield the **same date** as D7.

---

## 11. ⭐ MAJOR FINDING — `certificate_snapshots` bypasses the generator entirely

Found 2026-08-25 while checking whether a certificate migration was even permitted under decision 10. **It was nearly missed**: the planned next step was "migrate issued certificates", which decision 10 forbids inventing — the right move was to check what D7 *already persists*, and it persists a great deal.

### 11a. The mechanism

`certificate.pages.inc:118`:

```php
if (variable_get('certificate_snapshots', 0)
    && $snapshot = certificate_snapshot_load($account, $node, $template->nid)) {
  $output = $snapshot['snapshot'];          // FROZEN HTML FROM THE DB
}
else {
  $output = '<html>…' . theme('certificate_certificate', …) . '…</html>';
  if (!$preview && variable_get('certificate_snapshots', 0)) {
    certificate_snapshot_save($snapshot);   // write-once, on first view
  }
}
```

**When a snapshot exists, `theme('certificate_certificate', …)` never runs.** `CertificateBodyBuilder` — the whole 290-line rewrite — and the entire `quiz_certificate` view sort question in §10 are **bypassed** for those users. Their certificate is stored HTML, produced once and never recomputed.

### 11b. Why this matters both ways

| | consequence |
|---|---|
| ✅ | Fidelity is *exact* for snapshotted users — D10 serves the same bytes. Golden-master comparison becomes trivial rather than approximate. |
| 🛑 | If the snapshots are not migrated, every affected user's certificate is **silently regenerated by new code** — a different document from the one they already downloaded. No error, nothing in the logs. |

### 11c. Facts that are easy to get wrong

- **`date` is the FIRST-RENDER timestamp** (`getdate()[0]`), *not* the course completion date. Do not present it as completion, and do not recompute it from quiz results.
- **`cid = 0` means "legacy, template unknown"** and D7 matches it against **any** template: `WHERE uid = :uid AND nid = :nid AND (cid = 0 OR cid = :cid)`. Mapping those zeros to a real template — which looks like fixing missing data — would make every legacy snapshot **miss**, regenerating certificates frozen for years.
- **D7 takes the first row with no `ORDER BY`.** Where a user has both a legacy and a specific snapshot, storage order decides — the same class of problem as row `[0]` in §10. S-4 measures it. **No sort was invented.**
- **Some snapshots may be frozen branch-C refusals** — a user who viewed too early had *"…does not provide certifiacte"* stored permanently. Those migrate too; that is what D7 serves them today.
- **~228 KB of inline base64 per row.** Self-contained, so no file migration needed — but check S-2's `total_mb` before running.

### 11d. Decision 10 compliance

This creates **no** issued-certificate entity, serial number, new issue date, verification route, or revocation system — all forbidden. It carries over three **existing** D7 tables holding **existing** rows, which §27 requires be preserved. Inventing an entity on top would be the violation; moving the table is preservation.

### 11e. 🛑 The setting must be captured, not assumed

`ce_certificate.settings.yml` ships `snapshots_enabled: false` to match D7's `variable_get(…, 0)` default. That is a matching *default*, not a measurement:

| D7 | D10 | result |
|---|---|---|
| ON | OFF | existing certificates silently **regenerated** |
| OFF | ON | certificates **frozen** that D7 rebuilds each time |

Both are silent behaviour changes; neither errors. **S-1 settles it.**

### 11f. Files

| File | Role |
|---|---|
| `scripts/reconcile/d7_certificate_snapshots.sh` | S-1…S-7 capture, **9 SQL blocks, all SELECT-only (verified)** |
| `ce_certificate/ce_certificate.install` | the three D7 tables, columns unchanged |
| `ce_certificate/src/CertificateSnapshotStorage.php` | reproduces `(cid = 0 OR cid = :cid)` exactly; no invented sort |
| `ce_certificate/config/install/ce_certificate.settings.yml` | `snapshots_enabled: false`, pending S-1 |
| `ce_migrate/migrations/ce_certificate_snapshot.yml` | issued certificates, HTML copied byte-for-byte |
| `ce_migrate/migrations/ce_certificate_node.yml` | course → template mapping |
| `ce_migrate/migrations/ce_certificate_node_settings.yml` | orientation |

**Runtime-unverified.** Nothing here has been run against a database.
