@opacedev/astro-ai-content-checker 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/LICENSE +9 -0
  3. package/README.md +236 -0
  4. package/SBOM.cdx.json +172 -0
  5. package/SECURITY.md +9 -0
  6. package/THIRD_PARTY_NOTICES.md +36 -0
  7. package/dist/build-report-html.d.ts +3 -0
  8. package/dist/highlight.js +155 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +43 -0
  11. package/dist/options.d.ts +21 -0
  12. package/dist/options.js +6 -0
  13. package/dist/receipt.d.ts +15 -0
  14. package/dist/receipt.js +100 -0
  15. package/dist/report.d.ts +55 -0
  16. package/dist/report.js +12 -0
  17. package/dist/sections.js +181 -0
  18. package/dist/share.d.ts +47 -0
  19. package/dist/share.js +72 -0
  20. package/dist/shared-EX54KI6H.js +1503 -0
  21. package/dist/shared-MMXWTX2U.js +43 -0
  22. package/dist/toolbar.js +13478 -0
  23. package/node_modules/@opacedev/ai-content-checker-browser/LICENSE +21 -0
  24. package/node_modules/@opacedev/ai-content-checker-browser/README.md +145 -0
  25. package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.d.ts +14 -0
  26. package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.js +47 -0
  27. package/node_modules/@opacedev/ai-content-checker-browser/dist/index.d.ts +3 -0
  28. package/node_modules/@opacedev/ai-content-checker-browser/dist/index.js +99 -0
  29. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.d.ts +12 -0
  30. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.js +17 -0
  31. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.d.ts +1 -0
  32. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.js +3984 -0
  33. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.d.ts +30 -0
  34. package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.js +1 -0
  35. package/node_modules/@opacedev/ai-content-checker-browser/package.json +60 -0
  36. package/node_modules/@opacedev/ai-content-checker-contracts/LICENSE +21 -0
  37. package/node_modules/@opacedev/ai-content-checker-contracts/README.md +93 -0
  38. package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.d.ts +58 -0
  39. package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.js +17 -0
  40. package/node_modules/@opacedev/ai-content-checker-contracts/package.json +57 -0
  41. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-request.schema.d.ts +44 -0
  42. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-result.schema.d.ts +122 -0
  43. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/candidate.schema.d.ts +81 -0
  44. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/capabilities.schema.d.ts +24 -0
  45. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/checker-result.schema.d.ts +342 -0
  46. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/common.schema.d.ts +10 -0
  47. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/envelope.schema.d.ts +14 -0
  48. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/gate-result.schema.d.ts +19 -0
  49. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/integrity-receipt.schema.d.ts +122 -0
  50. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/job.schema.d.ts +188 -0
  51. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/method-result.schema.d.ts +39 -0
  52. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/pattern-finding.schema.d.ts +26 -0
  53. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/protected-span.schema.d.ts +35 -0
  54. package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/rewrite-request.schema.d.ts +65 -0
  55. package/node_modules/@opacedev/ai-content-checker-contracts/src/index.ts +63 -0
  56. package/node_modules/@opacedev/ai-content-checker-contracts/src/semantic-validation.mjs +32 -0
  57. package/node_modules/@opacedev/ai-content-checker-core/LICENSE +21 -0
  58. package/node_modules/@opacedev/ai-content-checker-core/README.md +186 -0
  59. package/node_modules/@opacedev/ai-content-checker-core/dist/bundle.js +4744 -0
  60. package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.d.ts +16 -0
  61. package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.js +7 -0
  62. package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.d.ts +17 -0
  63. package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.js +76 -0
  64. package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.d.ts +27 -0
  65. package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.js +47 -0
  66. package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.d.ts +14 -0
  67. package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.js +16 -0
  68. package/node_modules/@opacedev/ai-content-checker-core/dist/index.d.ts +15 -0
  69. package/node_modules/@opacedev/ai-content-checker-core/dist/index.js +15 -0
  70. package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.d.ts +8 -0
  71. package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.js +84 -0
  72. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.d.ts +12 -0
  73. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.js +68 -0
  74. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.d.ts +73 -0
  75. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.js +499 -0
  76. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.d.ts +76 -0
  77. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.js +1067 -0
  78. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.d.ts +165 -0
  79. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.js +614 -0
  80. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.d.ts +19 -0
  81. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.js +542 -0
  82. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.d.ts +40 -0
  83. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.js +40 -0
  84. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.d.ts +138 -0
  85. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.js +200 -0
  86. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.d.ts +71 -0
  87. package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.js +388 -0
  88. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.d.ts +17 -0
  89. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.js +75 -0
  90. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.d.ts +3 -0
  91. package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.js +17 -0
  92. package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.d.ts +55 -0
  93. package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.js +86 -0
  94. package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.d.ts +48 -0
  95. package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.js +61 -0
  96. package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.d.ts +241 -0
  97. package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.js +419 -0
  98. package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.d.ts +34 -0
  99. package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.js +51 -0
  100. package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.d.ts +3 -0
  101. package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.js +58 -0
  102. package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.d.ts +12 -0
  103. package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.js +30 -0
  104. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.d.ts +22 -0
  105. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.js +137 -0
  106. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.d.ts +16 -0
  107. package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.js +84 -0
  108. package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.d.ts +162 -0
  109. package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.js +501 -0
  110. package/node_modules/@opacedev/ai-content-checker-core/package.json +62 -0
  111. package/node_modules/canonicalize/LICENSE +201 -0
  112. package/node_modules/canonicalize/README.md +65 -0
  113. package/node_modules/canonicalize/bin/canonicalize.js +17 -0
  114. package/node_modules/canonicalize/lib/canonicalize.d.ts +2 -0
  115. package/node_modules/canonicalize/lib/canonicalize.js +74 -0
  116. package/node_modules/canonicalize/package.json +64 -0
  117. package/node_modules/entities/LICENSE +11 -0
  118. package/node_modules/entities/lib/decode.d.ts +211 -0
  119. package/node_modules/entities/lib/decode.d.ts.map +1 -0
  120. package/node_modules/entities/lib/decode.js +536 -0
  121. package/node_modules/entities/lib/decode.js.map +1 -0
  122. package/node_modules/entities/lib/decode_codepoint.d.ts +19 -0
  123. package/node_modules/entities/lib/decode_codepoint.d.ts.map +1 -0
  124. package/node_modules/entities/lib/decode_codepoint.js +76 -0
  125. package/node_modules/entities/lib/decode_codepoint.js.map +1 -0
  126. package/node_modules/entities/lib/encode.d.ts +22 -0
  127. package/node_modules/entities/lib/encode.d.ts.map +1 -0
  128. package/node_modules/entities/lib/encode.js +77 -0
  129. package/node_modules/entities/lib/encode.js.map +1 -0
  130. package/node_modules/entities/lib/escape.d.ts +43 -0
  131. package/node_modules/entities/lib/escape.d.ts.map +1 -0
  132. package/node_modules/entities/lib/escape.js +122 -0
  133. package/node_modules/entities/lib/escape.js.map +1 -0
  134. package/node_modules/entities/lib/esm/decode.d.ts +211 -0
  135. package/node_modules/entities/lib/esm/decode.d.ts.map +1 -0
  136. package/node_modules/entities/lib/esm/decode.js +496 -0
  137. package/node_modules/entities/lib/esm/decode.js.map +1 -0
  138. package/node_modules/entities/lib/esm/decode_codepoint.d.ts +19 -0
  139. package/node_modules/entities/lib/esm/decode_codepoint.d.ts.map +1 -0
  140. package/node_modules/entities/lib/esm/decode_codepoint.js +71 -0
  141. package/node_modules/entities/lib/esm/decode_codepoint.js.map +1 -0
  142. package/node_modules/entities/lib/esm/encode.d.ts +22 -0
  143. package/node_modules/entities/lib/esm/encode.d.ts.map +1 -0
  144. package/node_modules/entities/lib/esm/encode.js +69 -0
  145. package/node_modules/entities/lib/esm/encode.js.map +1 -0
  146. package/node_modules/entities/lib/esm/escape.d.ts +43 -0
  147. package/node_modules/entities/lib/esm/escape.d.ts.map +1 -0
  148. package/node_modules/entities/lib/esm/escape.js +116 -0
  149. package/node_modules/entities/lib/esm/escape.js.map +1 -0
  150. package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts +3 -0
  151. package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts.map +1 -0
  152. package/node_modules/entities/lib/esm/generated/decode-data-html.js +7 -0
  153. package/node_modules/entities/lib/esm/generated/decode-data-html.js.map +1 -0
  154. package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts +3 -0
  155. package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts.map +1 -0
  156. package/node_modules/entities/lib/esm/generated/decode-data-xml.js +7 -0
  157. package/node_modules/entities/lib/esm/generated/decode-data-xml.js.map +1 -0
  158. package/node_modules/entities/lib/esm/generated/encode-html.d.ts +8 -0
  159. package/node_modules/entities/lib/esm/generated/encode-html.d.ts.map +1 -0
  160. package/node_modules/entities/lib/esm/generated/encode-html.js +10 -0
  161. package/node_modules/entities/lib/esm/generated/encode-html.js.map +1 -0
  162. package/node_modules/entities/lib/esm/index.d.ts +96 -0
  163. package/node_modules/entities/lib/esm/index.d.ts.map +1 -0
  164. package/node_modules/entities/lib/esm/index.js +99 -0
  165. package/node_modules/entities/lib/esm/index.js.map +1 -0
  166. package/node_modules/entities/lib/esm/package.json +1 -0
  167. package/node_modules/entities/lib/generated/decode-data-html.d.ts +3 -0
  168. package/node_modules/entities/lib/generated/decode-data-html.d.ts.map +1 -0
  169. package/node_modules/entities/lib/generated/decode-data-html.js +9 -0
  170. package/node_modules/entities/lib/generated/decode-data-html.js.map +1 -0
  171. package/node_modules/entities/lib/generated/decode-data-xml.d.ts +3 -0
  172. package/node_modules/entities/lib/generated/decode-data-xml.d.ts.map +1 -0
  173. package/node_modules/entities/lib/generated/decode-data-xml.js +9 -0
  174. package/node_modules/entities/lib/generated/decode-data-xml.js.map +1 -0
  175. package/node_modules/entities/lib/generated/encode-html.d.ts +8 -0
  176. package/node_modules/entities/lib/generated/encode-html.d.ts.map +1 -0
  177. package/node_modules/entities/lib/generated/encode-html.js +12 -0
  178. package/node_modules/entities/lib/generated/encode-html.js.map +1 -0
  179. package/node_modules/entities/lib/index.d.ts +96 -0
  180. package/node_modules/entities/lib/index.d.ts.map +1 -0
  181. package/node_modules/entities/lib/index.js +126 -0
  182. package/node_modules/entities/lib/index.js.map +1 -0
  183. package/node_modules/entities/package.json +90 -0
  184. package/node_modules/entities/readme.md +122 -0
  185. package/package.json +91 -0
@@ -0,0 +1,388 @@
1
+ import { ABSTRACT_CLAIM_RE, ABSTRACT_PUNCH_RE, CONCRETE_ACTION_VERB_RE, CONTRAST_VARIANT_RES, MIC_DROP_CONTRAST_RE, REGISTER_FUNCTION_WORDS, REGISTER_LONG_WORD_LEN, V4_THRESHOLDS, } from "./en-signals-v4-data.js";
2
+ import { REFERENCE_CORPUS } from "./en-signals-v4-corpus.js";
3
+ import { NOT_JUST_CONTRAST } from "./en-signals-v2-data.js";
4
+ import { NEG_PARALLELISM_RE } from "./en-signals-v3-data.js";
5
+ const T = V4_THRESHOLDS;
6
+ function countWords(text) {
7
+ return (text.match(/\S+/g) ?? []).length;
8
+ }
9
+ function countMatches(re, text) {
10
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
11
+ let n = 0;
12
+ while (g.exec(text) !== null) {
13
+ n += 1;
14
+ if (g.lastIndex === 0)
15
+ break;
16
+ }
17
+ return n;
18
+ }
19
+ /** Terminator-preserving sentence split inside one paragraph (same split the
20
+ * v3 staccato rule uses, so the two layers agree on sentence boundaries). */
21
+ function paragraphSentences(paraText) {
22
+ return paraText.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter((s) => s.length > 0);
23
+ }
24
+ /** Digit / currency / percent token — the concrete-specific marker. */
25
+ const NUMERIC_RE = /[£$€]?\d|\d%/;
26
+ /** Capitalised word after a lowercase word — proper-noun-mid-sentence proxy. */
27
+ const MID_CAP_RE = /(?<=[a-z][,;]?\s)[A-Z][a-z]{2,}/;
28
+ function isAbstractShort(sentence) {
29
+ if (NUMERIC_RE.test(sentence))
30
+ return false;
31
+ if (MID_CAP_RE.test(sentence))
32
+ return false;
33
+ return ABSTRACT_PUNCH_RE.test(sentence);
34
+ }
35
+ // ─── Sentence-length spectral flatness (windowed DFT) ────────────────
36
+ // The empiricist's slFlat signal with the plan's length-artefact correction:
37
+ // flatness is computed on fixed 12-sentence windows only, then averaged, so
38
+ // series length never enters the statistic and short texts are exempt.
39
+ export function spectralFlatness(sentenceWordCounts) {
40
+ const W = T.spectralWindowSentences;
41
+ const windows = Math.floor(sentenceWordCounts.length / W);
42
+ if (windows < T.spectralMinWindows)
43
+ return null;
44
+ const EPS = 1e-9;
45
+ let sum = 0;
46
+ for (let w = 0; w < windows; w += 1) {
47
+ const seg = sentenceWordCounts.slice(w * W, (w + 1) * W);
48
+ const mean = seg.reduce((a, b) => a + b, 0) / W;
49
+ const x = seg.map((v) => v - mean);
50
+ let logSum = 0;
51
+ let linSum = 0;
52
+ const bins = Math.floor(W / 2);
53
+ for (let k = 1; k <= bins; k += 1) {
54
+ let re = 0;
55
+ let im = 0;
56
+ for (let n = 0; n < W; n += 1) {
57
+ const ang = (2 * Math.PI * k * n) / W;
58
+ re += x[n] * Math.cos(ang);
59
+ im -= x[n] * Math.sin(ang);
60
+ }
61
+ const p = re * re + im * im + EPS;
62
+ logSum += Math.log(p);
63
+ linSum += p;
64
+ }
65
+ const flat = Math.exp(logSum / bins) / (linSum / bins);
66
+ sum += flat;
67
+ }
68
+ return sum / windows;
69
+ }
70
+ // ─── Conditional-compression estimator ───────────────────────────────
71
+ // Deterministic LZ77 bit-cost estimate: greedy longest match (min 4, max 258
72
+ // chars) against a hash-chain index over [dictionary + already-scanned
73
+ // target]; literals cost 9 bits, a match costs 13 + log2(distance) bits —
74
+ // a deflate-shaped model, not deflate itself (see module header).
75
+ const HASH_SPAN = 4;
76
+ function hashAt(s, i) {
77
+ return (((s.charCodeAt(i) * 131 + s.charCodeAt(i + 1)) * 131 + s.charCodeAt(i + 2)) * 131 +
78
+ s.charCodeAt(i + 3)) >>> 0;
79
+ }
80
+ const MAX_CHAIN = 32;
81
+ const MAX_MATCH = 258;
82
+ function indexInto(index, s, from, to) {
83
+ for (let i = from; i <= to - HASH_SPAN; i += 1) {
84
+ const h = hashAt(s, i);
85
+ let chain = index.get(h);
86
+ if (!chain) {
87
+ chain = [];
88
+ index.set(h, chain);
89
+ }
90
+ chain.push(i);
91
+ if (chain.length > MAX_CHAIN * 2)
92
+ chain.splice(0, chain.length - MAX_CHAIN);
93
+ }
94
+ }
95
+ /** Cached hash-chain index of the shipped reference corpus (built once). */
96
+ let corpusIndex = null;
97
+ function getCorpusIndex() {
98
+ if (corpusIndex === null) {
99
+ corpusIndex = new Map();
100
+ indexInto(corpusIndex, REFERENCE_CORPUS, 0, REFERENCE_CORPUS.length);
101
+ }
102
+ return corpusIndex;
103
+ }
104
+ /**
105
+ * Estimated LZ77 cost in bits of `target`, optionally conditioned on the
106
+ * shipped reference corpus as a pre-loaded dictionary.
107
+ */
108
+ export function lzCostBits(target, withCorpusPrior) {
109
+ const dict = withCorpusPrior ? REFERENCE_CORPUS : "";
110
+ const combined = withCorpusPrior ? dict + target : target;
111
+ const base = dict.length;
112
+ // Self-referential chains for the target region, built as we scan.
113
+ const selfIndex = new Map();
114
+ const dictIndex = withCorpusPrior ? getCorpusIndex() : null;
115
+ let bits = 0;
116
+ let i = base;
117
+ while (i < combined.length) {
118
+ let bestLen = 0;
119
+ let bestDist = 0;
120
+ if (i + HASH_SPAN <= combined.length) {
121
+ const h = hashAt(combined, i);
122
+ const tryChain = (chain, offset) => {
123
+ if (!chain)
124
+ return;
125
+ const start = Math.max(0, chain.length - MAX_CHAIN);
126
+ for (let c = chain.length - 1; c >= start; c -= 1) {
127
+ const j = chain[c] + offset;
128
+ if (j >= i)
129
+ continue;
130
+ let len = 0;
131
+ const maxLen = Math.min(MAX_MATCH, combined.length - i);
132
+ while (len < maxLen && combined.charCodeAt(j + len) === combined.charCodeAt(i + len))
133
+ len += 1;
134
+ if (len > bestLen) {
135
+ bestLen = len;
136
+ bestDist = i - j;
137
+ }
138
+ }
139
+ };
140
+ tryChain(selfIndex.get(h), base);
141
+ if (dictIndex)
142
+ tryChain(dictIndex.get(h), 0);
143
+ }
144
+ if (bestLen >= HASH_SPAN) {
145
+ bits += 13 + Math.log2(bestDist);
146
+ // Index the covered positions (sampled every 2 to bound cost).
147
+ for (let k = i; k < i + bestLen && k + HASH_SPAN <= combined.length; k += 2) {
148
+ const h2 = hashAt(combined, k);
149
+ let chain = selfIndex.get(h2);
150
+ if (!chain) {
151
+ chain = [];
152
+ selfIndex.set(h2, chain);
153
+ }
154
+ chain.push(k - base);
155
+ if (chain.length > MAX_CHAIN * 2)
156
+ chain.splice(0, chain.length - MAX_CHAIN);
157
+ }
158
+ i += bestLen;
159
+ }
160
+ else {
161
+ bits += 9;
162
+ if (i + HASH_SPAN <= combined.length) {
163
+ const h2 = hashAt(combined, i);
164
+ let chain = selfIndex.get(h2);
165
+ if (!chain) {
166
+ chain = [];
167
+ selfIndex.set(h2, chain);
168
+ }
169
+ chain.push(i - base);
170
+ if (chain.length > MAX_CHAIN * 2)
171
+ chain.splice(0, chain.length - MAX_CHAIN);
172
+ }
173
+ i += 1;
174
+ }
175
+ }
176
+ return bits;
177
+ }
178
+ /** Relative compression gain from the human-corpus prior (0 = no gain). */
179
+ export function compressionGain(text) {
180
+ const solo = lzCostBits(text, false);
181
+ if (solo <= 0)
182
+ return 0;
183
+ const cond = lzCostBits(text, true);
184
+ return 1 - cond / solo;
185
+ }
186
+ export function registerProfile(text) {
187
+ const tokens = text.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
188
+ const counts = new Map();
189
+ let long = 0;
190
+ for (const t of tokens) {
191
+ counts.set(t, (counts.get(t) ?? 0) + 1);
192
+ if (t.length >= REGISTER_LONG_WORD_LEN)
193
+ long += 1;
194
+ }
195
+ const n = Math.max(1, tokens.length);
196
+ return {
197
+ func: REGISTER_FUNCTION_WORDS.map((w) => (counts.get(w) ?? 0) / n),
198
+ longWordShare: long / n,
199
+ tokenCount: tokens.length,
200
+ };
201
+ }
202
+ let refProfile = null;
203
+ export function referenceRegisterProfile() {
204
+ if (refProfile === null)
205
+ refProfile = registerProfile(REFERENCE_CORPUS);
206
+ return refProfile;
207
+ }
208
+ export function registerDistance(text) {
209
+ const p = registerProfile(text);
210
+ const r = referenceRegisterProfile();
211
+ let l1 = 0;
212
+ for (let i = 0; i < p.func.length; i += 1)
213
+ l1 += Math.abs(p.func[i] - r.func[i]);
214
+ return { funcL1: l1, longWordDelta: p.longWordShare - r.longWordShare };
215
+ }
216
+ function collectMetrics(doc) {
217
+ const { text, wordCount, paragraphs } = doc;
218
+ // Sentence series for the spectral estimator: terminator-split across the
219
+ // whole document (paragraph splits preserved via per-paragraph splitting).
220
+ const allSentences = [];
221
+ for (const p of paragraphs)
222
+ allSentences.push(...paragraphSentences(p.text));
223
+ const counts = allSentences.map(countWords).filter((c) => c > 0);
224
+ const flat = spectralFlatness(counts);
225
+ const gain = wordCount >= T.compressionMinWords && wordCount <= T.compressionMaxWords
226
+ ? compressionGain(text)
227
+ : null;
228
+ const reg = registerDistance(text);
229
+ // Punchline fragments.
230
+ let punchCount = 0;
231
+ let punchFinal = 0;
232
+ for (const p of paragraphs) {
233
+ const sents = paragraphSentences(p.text);
234
+ for (let i = 0; i < sents.length; i += 1) {
235
+ const s = sents[i];
236
+ const wc = countWords(s);
237
+ if (wc > 0 && wc <= 8 && /[.!?]$/.test(s) && isAbstractShort(s)) {
238
+ punchCount += 1;
239
+ if (i === sents.length - 1)
240
+ punchFinal += 1;
241
+ }
242
+ }
243
+ }
244
+ const punchRate = allSentences.length > 0 ? punchCount / allSentences.length : 0;
245
+ // Mic-drop paragraphs.
246
+ let micDrops = 0;
247
+ for (const p of paragraphs) {
248
+ const sents = paragraphSentences(p.text);
249
+ if (sents.length < 4)
250
+ continue;
251
+ const closer = sents[sents.length - 1];
252
+ const closerWc = countWords(closer);
253
+ const setupWcs = sents.slice(0, -1).map(countWords).filter((c) => c >= 12);
254
+ if (setupWcs.length < 3)
255
+ continue;
256
+ const setupMean = setupWcs.reduce((a, b) => a + b, 0) / setupWcs.length;
257
+ if (closerWc > 0 && closerWc <= 8 && closerWc <= 0.45 * setupMean &&
258
+ !NUMERIC_RE.test(closer) && !MID_CAP_RE.test(closer) &&
259
+ MIC_DROP_CONTRAST_RE.test(closer)) {
260
+ micDrops += 1;
261
+ }
262
+ }
263
+ // Contrast constructions (rate layer over the existing detections).
264
+ let contrastCount = 0;
265
+ for (const re of NOT_JUST_CONTRAST)
266
+ contrastCount += countMatches(re, text);
267
+ contrastCount += countMatches(NEG_PARALLELISM_RE, text);
268
+ for (const re of CONTRAST_VARIANT_RES)
269
+ contrastCount += countMatches(re, text);
270
+ const contrastPer1000 = wordCount > 0 ? contrastCount / (wordCount / 1000) : 0;
271
+ // Rhetorical vs procedural. Heuristic (documented): CONCRETE = the sentence
272
+ // contains a number/currency/percent token, a mid-sentence capitalised word
273
+ // (proper-noun proxy) or a specific action verb; ABSTRACT = not concrete
274
+ // AND (linking-verb + intangible pay-off word, or a ≤8-word declarative).
275
+ let abstract = 0;
276
+ let concrete = 0;
277
+ for (const s of allSentences) {
278
+ const isConcrete = NUMERIC_RE.test(s) || MID_CAP_RE.test(s) || CONCRETE_ACTION_VERB_RE.test(s);
279
+ if (isConcrete) {
280
+ concrete += 1;
281
+ }
282
+ else if (ABSTRACT_CLAIM_RE.test(s) || countWords(s) <= 8) {
283
+ abstract += 1;
284
+ }
285
+ }
286
+ const share = allSentences.length > 0 ? abstract / allSentences.length : 0;
287
+ return {
288
+ wordCount,
289
+ sentenceCount: allSentences.length,
290
+ spectralFlatness: flat,
291
+ compressionGain: gain,
292
+ registerFuncL1: reg.funcL1,
293
+ registerLongWordDelta: reg.longWordDelta,
294
+ punchlineCount: punchCount,
295
+ punchlineRate: punchRate,
296
+ punchlineParagraphFinal: punchFinal,
297
+ micDropParagraphs: micDrops,
298
+ contrastCount,
299
+ contrastPer1000,
300
+ ratioSentences: allSentences.length,
301
+ ratioAbstract: abstract,
302
+ ratioConcrete: concrete,
303
+ ratioAbstractShare: share,
304
+ };
305
+ }
306
+ /**
307
+ * Standalone metric computation for the calibration script
308
+ * (tests/battery/calibrate.mjs). Mirrors exactly what collectV4Issues
309
+ * measures; not part of the public package API.
310
+ */
311
+ export function computeV4Metrics(text) {
312
+ const paragraphs = [];
313
+ const re = /\n\s*\n/g;
314
+ let last = 0;
315
+ let m;
316
+ while ((m = re.exec(text)) !== null) {
317
+ paragraphs.push({ text: text.slice(last, m.index), start: last });
318
+ last = m.index + m[0].length;
319
+ }
320
+ paragraphs.push({ text: text.slice(last), start: last });
321
+ return collectMetrics({
322
+ text,
323
+ wordCount: countWords(text),
324
+ sentences: [],
325
+ paragraphs: paragraphs.filter((p) => p.text.trim().length > 0),
326
+ });
327
+ }
328
+ // ─── Rule evaluation ─────────────────────────────────────────────────
329
+ const round = (v, dp = 3) => Math.round(v * 10 ** dp) / 10 ** dp;
330
+ export function collectV4Issues(ctx) {
331
+ const m = collectMetrics(ctx);
332
+ const { pushEx } = ctx;
333
+ if (m.spectralFlatness !== null && m.spectralFlatness < T.spectralFlatnessMax) {
334
+ pushEx("sentence-length-spectral-flatness", `window-averaged spectral flatness ${round(m.spectralFlatness)} (threshold ${T.spectralFlatnessMax})`, null, null, {
335
+ extra: {
336
+ spectral_flatness: round(m.spectralFlatness),
337
+ window_sentences: T.spectralWindowSentences,
338
+ sentence_count: m.sentenceCount,
339
+ },
340
+ });
341
+ }
342
+ if (m.compressionGain !== null && m.compressionGain < T.compressionGainMin) {
343
+ pushEx("conditional-compression", `human-prior compression gain ${round(m.compressionGain)} (threshold ${T.compressionGainMin})`, null, null, {
344
+ extra: { compression_gain: round(m.compressionGain), reference_corpus: "en-signals-v4-corpus 2026.08.5 (public-domain, pre-1929)" },
345
+ });
346
+ }
347
+ if (m.wordCount >= T.registerMinWords &&
348
+ m.registerFuncL1 > T.registerFuncL1Min &&
349
+ m.registerLongWordDelta > T.registerLongWordDeltaMin) {
350
+ pushEx("lexical-register-distance", `function-word L1 ${round(m.registerFuncL1)} + long-word share +${round(m.registerLongWordDelta)} vs human reference`, null, null, {
351
+ extra: {
352
+ function_word_l1: round(m.registerFuncL1),
353
+ long_word_share_delta: round(m.registerLongWordDelta),
354
+ genre_caveat: "specialised genres legitimately measure as distant; corroboration only",
355
+ },
356
+ });
357
+ }
358
+ if (m.punchlineCount >= T.punchlineMinCount &&
359
+ m.punchlineRate >= T.punchlineMinRate &&
360
+ m.punchlineParagraphFinal >= T.punchlineMinParagraphFinal) {
361
+ pushEx("punchline-fragment-density", `${m.punchlineCount} abstract punchline fragments in ${m.sentenceCount} sentences (${m.punchlineParagraphFinal} paragraph-final)`, null, null, {
362
+ count: m.punchlineCount,
363
+ extra: { punchline_rate: round(m.punchlineRate), paragraph_final: m.punchlineParagraphFinal },
364
+ });
365
+ }
366
+ if (m.micDropParagraphs >= T.micDropMinParagraphs) {
367
+ pushEx("mic-drop-paragraph", `${m.micDropParagraphs} paragraphs end in a short abstract contrast closer`, null, null, { count: m.micDropParagraphs });
368
+ }
369
+ if (m.contrastCount >= T.contrastMinCount && m.contrastPer1000 >= T.contrastMinPer1000) {
370
+ pushEx("contrast-density", `${m.contrastCount} contrast constructions (${round(m.contrastPer1000, 1)}/1000 words)`, null, null, {
371
+ count: m.contrastCount,
372
+ extra: { rate_per_1000_words: round(m.contrastPer1000, 1) },
373
+ });
374
+ }
375
+ if (m.ratioSentences >= T.ratioMinSentences &&
376
+ m.ratioAbstract >= T.ratioMinAbstract &&
377
+ m.ratioAbstractShare >= T.ratioMinShare &&
378
+ m.ratioConcrete <= T.ratioMaxConcrete) {
379
+ pushEx("rhetorical-procedural-ratio", `${m.ratioAbstract} abstract-claim vs ${m.ratioConcrete} concrete-action sentences of ${m.ratioSentences}`, null, null, {
380
+ count: m.ratioAbstract,
381
+ extra: {
382
+ abstract_sentences: m.ratioAbstract,
383
+ concrete_sentences: m.ratioConcrete,
384
+ abstract_share: round(m.ratioAbstractShare),
385
+ },
386
+ });
387
+ }
388
+ }
@@ -0,0 +1,17 @@
1
+ import type { ProtectedSpan } from "@opacedev/ai-content-checker-contracts";
2
+ type Source = {
3
+ content: string;
4
+ content_hash?: string;
5
+ };
6
+ type Policy = {
7
+ configured_terms?: Array<{
8
+ text: string;
9
+ kind?: "name" | "organisation";
10
+ }>;
11
+ user_spans?: Array<{
12
+ start_utf16: number;
13
+ end_utf16: number;
14
+ }>;
15
+ };
16
+ export declare function extractProtectedSpans(source: Source, policy?: Policy): ProtectedSpan[];
17
+ export {};
@@ -0,0 +1,75 @@
1
+ import { rangeFromUtf16 } from "../source/offsets.js";
2
+ import { prefixedSha256 } from "../source/utf8.js";
3
+ const RULES = [
4
+ ["code", /```[\s\S]*?```|`[^`\n]+`/g], ["url", /https?:\/\/[^\s<>)\]]+/g], ["email", /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi],
5
+ ["citation", /\[[0-9]+\]|\((?:[A-Z][A-Za-z-]+(?:\s+(?:and|&)\s+[A-Z][A-Za-z-]+)*(?:\s+et al\.)?),?\s+\d{4}[a-z]?\)|\b[A-Z][A-Za-z-]+(?:\s+(?:and|&)\s+[A-Z][A-Za-z-]+)*\s+et al\.,?\s*\(?\d{4}[a-z]?\)?/g], ["quote", /[“"][^”"\n]+[”"]/g],
6
+ ["currency", /(?:£|\$|€)\s?\d[\d,]*(?:\.\d+)?/g], ["date", /\b(?:\d{1,2}\s+[A-Z][a-z]+\s+\d{4}|\d{4}-\d{2}-\d{2})\b/g],
7
+ ["time", /\b\d{1,2}:\d{2}(?:\s?[ap]m)?\b/gi], ["unit", /\b\d+(?:\.\d+)?\s?(?:kg|g|km|m|cm|mm|GB|MB|%|°C)\b/g], ["number", /\b\d[\d,]*(?:\.\d+)?%?\b/g]
8
+ ];
9
+ // Deterministic, precision-first entity rules. It is acceptable to miss a name; it is not acceptable to flag ordinary sentence-start words.
10
+ const HONORIFIC_NAME = /\b(?:Dr|Mr|Mrs|Ms|Prof|Sir|Dame)\.?\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}/g;
11
+ const NAME_RUN = /\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2}\b/g;
12
+ const ORG_SUFFIXED = /\b(?:[A-Z][A-Za-z&.'-]*\s+){1,5}(?:Ltd\.?|Limited|LLC|Inc\.?|plc|GmbH|&\s?Co\.?|Agency|Council|University)(?!\w)/g;
13
+ const ORG_ACRONYM = /\b[A-Z]{2,6}(?:\.[A-Z]{2,6})*\b/g;
14
+ const NAME_STOPLIST = new Set("The A An This That These Those It He She They We You I In On At For To From With By Of And But Or Nor If As Is Are Was Were Be Been Not No Yes See Run New Our Your Their His Her Its My Do Does Did Will Would Can Could Should May Might Must Have Has Had So Then There Here What Which Who Whose When Where Why How All Any Each Per Both More Most Some Such Other Also Just Only Now Today Yesterday Tomorrow Please Note".split(" "));
15
+ const ACRONYM_STOPLIST = new Set(["AM", "PM", "GMT", "UTC", "BST", "CET", "CEST", "EST", "EDT", "PST", "PDT", "EUR", "USD", "GBP", "JPY", "CHF", "AI", "IT", "TV", "OK", "PS", "NB", "ID", "IP", "FAQ", "API", "URL", "URI", "HTML", "CSS", "SQL", "PDF", "HTTP", "HTTPS", "VAT", "ASAP", "DIY", "CEO", "CTO", "CFO", "COO", "UK", "EU", "US", "USA", "RSVP", "ETA", "FYI", "QA", "DNA", "GPS", "SMS"]);
16
+ function atSentenceStart(content, index) { const before = content.slice(0, index).replace(/[\s"“”'‘’(\[]+$/, ""); return before === "" || /[.!?:;…]$/.test(before); }
17
+ function extractEntitySpans(content) {
18
+ const found = [];
19
+ const overlaps = (start, end) => found.some(x => start < x.start + x.text.length && x.start < end);
20
+ for (const m of content.matchAll(ORG_SUFFIXED)) {
21
+ let text = m[0], start = m.index;
22
+ for (;;) {
23
+ const lead = /^([A-Z][A-Za-z&.'-]*)\s+/.exec(text);
24
+ if (lead && NAME_STOPLIST.has(lead[1])) {
25
+ start += lead[0].length;
26
+ text = text.slice(lead[0].length);
27
+ }
28
+ else
29
+ break;
30
+ }
31
+ if (/\s/.test(text))
32
+ found.push({ kind: "organisation", text, start });
33
+ }
34
+ for (const m of content.matchAll(ORG_ACRONYM)) {
35
+ if (!ACRONYM_STOPLIST.has(m[0]) && !overlaps(m.index, m.index + m[0].length))
36
+ found.push({ kind: "organisation", text: m[0], start: m.index });
37
+ }
38
+ for (const m of content.matchAll(HONORIFIC_NAME)) {
39
+ if (!overlaps(m.index, m.index + m[0].length))
40
+ found.push({ kind: "name", text: m[0], start: m.index });
41
+ }
42
+ for (const m of content.matchAll(NAME_RUN)) {
43
+ if (atSentenceStart(content, m.index) || overlaps(m.index, m.index + m[0].length))
44
+ continue;
45
+ if (m[0].split(/\s+/).some(token => NAME_STOPLIST.has(token)))
46
+ continue;
47
+ found.push({ kind: "name", text: m[0], start: m.index });
48
+ }
49
+ return found;
50
+ }
51
+ export function extractProtectedSpans(source, policy = {}) {
52
+ const hash = source.content_hash ?? prefixedSha256(source.content);
53
+ const spans = [];
54
+ const add = (text, start, kind, origin, protection = "exact") => { const r = rangeFromUtf16(source.content, start, start + text.length); spans.push({ id: `ps_${kind}_${start}_${prefixedSha256(text).slice(7, 15)}`, kind, text, ...r, normalised_value: text, policy: protection, source: origin, confidence: null, content_hash: hash }); };
55
+ for (const [kind, regex] of RULES) {
56
+ regex.lastIndex = 0;
57
+ for (const m of source.content.matchAll(regex))
58
+ add(m[0], m.index, kind, "deterministic", kind === "date" || kind === "number" ? "equivalent_format" : "exact");
59
+ }
60
+ for (const entity of extractEntitySpans(source.content))
61
+ add(entity.text, entity.start, entity.kind, "deterministic");
62
+ for (const term of policy.configured_terms ?? []) {
63
+ let at = 0;
64
+ while ((at = source.content.indexOf(term.text, at)) >= 0) {
65
+ add(term.text, at, term.kind ?? "name", "user");
66
+ at += term.text.length;
67
+ }
68
+ }
69
+ for (const selected of policy.user_spans ?? [])
70
+ add(source.content.slice(selected.start_utf16, selected.end_utf16), selected.start_utf16, "user_selected", "user");
71
+ const unique = new Map();
72
+ for (const span of spans)
73
+ unique.set(`${span.start_utf16}:${span.end_utf16}:${span.kind}:${span.policy}`, span);
74
+ return [...unique.values()].sort((a, b) => a.start_utf16 - b.start_utf16 || (b.end_utf16 - b.start_utf16) - (a.end_utf16 - a.start_utf16) || a.id.localeCompare(b.id));
75
+ }
@@ -0,0 +1,3 @@
1
+ import type { GateResult, ProtectedSpan } from "@opacedev/ai-content-checker-contracts";
2
+ export declare function validateProtected(source: string, candidate: string, spans: ProtectedSpan[]): GateResult;
3
+ export declare function validateAdditions(source: string, candidate: string): GateResult;
@@ -0,0 +1,17 @@
1
+ export function validateProtected(source, candidate, spans) {
2
+ const failures = [];
3
+ for (const span of spans) {
4
+ const count = (candidate.split(span.text).length - 1);
5
+ if (count === 0)
6
+ failures.push({ protected_span_id: span.id, expected_hash: span.content_hash, observed: "missing" });
7
+ else if (count > source.split(span.text).length - 1)
8
+ failures.push({ protected_span_id: span.id, expected_hash: span.content_hash, observed: "duplicated" });
9
+ }
10
+ return { id: "protected_spans.exact", version: "1.0.0", status: failures.length ? "fail" : "pass", hard: true, summary: failures.length ? `${failures.length} protected item(s) changed` : "Protected items remain present", failures, limitations: [] };
11
+ }
12
+ export function validateAdditions(source, candidate) {
13
+ const pattern = /https?:\/\/[^\s<>)\]]+|```[\s\S]*?```|`[^`\n]+`|\[[0-9]+\]|[“"][^”"\n]+[”"]/g;
14
+ const original = new Set(source.match(pattern) ?? []);
15
+ const added = (candidate.match(pattern) ?? []).filter(x => !original.has(x));
16
+ return { id: "unsupported_additions", version: "1.0.0", status: added.length ? "fail" : "pass", hard: true, summary: added.length ? `${added.length} unsupported reference(s) added` : "No unsupported URLs, citations, quotations or code added", failures: added.map(observed => ({ observed })), limitations: [] };
17
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * C2PA text content credentials — detection only, never removal.
3
+ *
4
+ * C2PA Specification 2.3 (December 2025) added §A.8 "Embedding Manifests into
5
+ * Unstructured Text": a C2PA Manifest Store carried inside the text itself as
6
+ * non-rendering Unicode variation selectors, so that Content Credentials
7
+ * survive a copy and paste. The spec is explicit about the carrier set:
8
+ *
9
+ * "Unicode variation selectors (U+FE00-U+FE0F and U+E0100-U+E01EF) are used
10
+ * because they are specifically designed to be visually non-rendering while
11
+ * remaining part of the valid Unicode character set."
12
+ * — C2PA Specification 2.4, §A.8.2
13
+ *
14
+ * Those are the same code points this tool's hidden-character check flags, and
15
+ * U+FE00-U+FE0F carry fix:"remove", so the safe-fix path would strip every byte
16
+ * of the manifest whose value is 0-15. That silently breaks the magic number
17
+ * and the credential stops being detectable at all — not reported as corrupt,
18
+ * simply gone. This module exists so the checker can see a credential before it
19
+ * is offered a fix that would destroy it.
20
+ *
21
+ * Nothing here weakens the hidden-character detection. Every carrier is still
22
+ * found, still counted and still shown. The credential's characters are made
23
+ * ineligible for the automatic fix, and removing them becomes a separate,
24
+ * deliberate choice.
25
+ */
26
+ /** §A.8.3.2 — variationSelectorToByte. Returns null for anything else. */
27
+ export declare const variationSelectorToByte: (codePoint: number) => number | null;
28
+ /** True for any code point §A.8 can use as a manifest carrier, sentinel included. */
29
+ export declare const isCredentialCarrier: (codePoint: number) => boolean;
30
+ export interface C2paTextCredential {
31
+ /** UTF-16 offsets covering the U+FEFF sentinel and the whole wrapper. */
32
+ start_utf16: number;
33
+ end_utf16: number;
34
+ /** §A.8.2.3 version field. */
35
+ version: number;
36
+ /** §A.8.2.3 manifestLength, in bytes. */
37
+ manifest_length: number;
38
+ /**
39
+ * "ok" when the declared manifest is complete;
40
+ * "truncated" maps to the spec's manifest.text.corruptedWrapper condition.
41
+ */
42
+ status: "ok" | "truncated";
43
+ }
44
+ /**
45
+ * §A.8.4.2 detection algorithm: scan for U+FEFF, read the contiguous run of
46
+ * variation selectors that follows, decode the first eight bytes and compare
47
+ * them with the magic number.
48
+ */
49
+ export declare function detectC2paTextCredentials(text: string): C2paTextCredential[];
50
+ /** True when the span touches any detected credential. */
51
+ export declare const withinCredential: (credentials: readonly C2paTextCredential[], span: {
52
+ start_utf16: number;
53
+ end_utf16: number;
54
+ }) => boolean;
55
+ export declare const credentialNotice: (credentials: readonly C2paTextCredential[]) => string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * C2PA text content credentials — detection only, never removal.
3
+ *
4
+ * C2PA Specification 2.3 (December 2025) added §A.8 "Embedding Manifests into
5
+ * Unstructured Text": a C2PA Manifest Store carried inside the text itself as
6
+ * non-rendering Unicode variation selectors, so that Content Credentials
7
+ * survive a copy and paste. The spec is explicit about the carrier set:
8
+ *
9
+ * "Unicode variation selectors (U+FE00-U+FE0F and U+E0100-U+E01EF) are used
10
+ * because they are specifically designed to be visually non-rendering while
11
+ * remaining part of the valid Unicode character set."
12
+ * — C2PA Specification 2.4, §A.8.2
13
+ *
14
+ * Those are the same code points this tool's hidden-character check flags, and
15
+ * U+FE00-U+FE0F carry fix:"remove", so the safe-fix path would strip every byte
16
+ * of the manifest whose value is 0-15. That silently breaks the magic number
17
+ * and the credential stops being detectable at all — not reported as corrupt,
18
+ * simply gone. This module exists so the checker can see a credential before it
19
+ * is offered a fix that would destroy it.
20
+ *
21
+ * Nothing here weakens the hidden-character detection. Every carrier is still
22
+ * found, still counted and still shown. The credential's characters are made
23
+ * ineligible for the automatic fix, and removing them becomes a separate,
24
+ * deliberate choice.
25
+ */
26
+ /** §A.8.2.2 — magic = 0x4332504154585400, "C2PATXT\0". */
27
+ const MAGIC = [0x43, 0x32, 0x50, 0x41, 0x54, 0x58, 0x54, 0x00];
28
+ /** §A.8.2.2 — 8-byte magic, 1-byte version, 4-byte big-endian manifestLength. */
29
+ const HEADER_BYTES = 13;
30
+ /** §A.8.4.1 — the wrapper is prefixed with a single U+FEFF. */
31
+ const SENTINEL = 0xfeff;
32
+ /** §A.8.3.2 — variationSelectorToByte. Returns null for anything else. */
33
+ export const variationSelectorToByte = (codePoint) => codePoint >= 0xfe00 && codePoint <= 0xfe0f
34
+ ? codePoint - 0xfe00
35
+ : codePoint >= 0xe0100 && codePoint <= 0xe01ef
36
+ ? codePoint - 0xe0100 + 16
37
+ : null;
38
+ /** True for any code point §A.8 can use as a manifest carrier, sentinel included. */
39
+ export const isCredentialCarrier = (codePoint) => codePoint === SENTINEL || variationSelectorToByte(codePoint) !== null;
40
+ /**
41
+ * §A.8.4.2 detection algorithm: scan for U+FEFF, read the contiguous run of
42
+ * variation selectors that follows, decode the first eight bytes and compare
43
+ * them with the magic number.
44
+ */
45
+ export function detectC2paTextCredentials(text) {
46
+ const found = [];
47
+ for (let i = 0; i < text.length;) {
48
+ const codePoint = text.codePointAt(i);
49
+ const width = codePoint > 0xffff ? 2 : 1;
50
+ if (codePoint !== SENTINEL) {
51
+ i += width;
52
+ continue;
53
+ }
54
+ const bytes = [];
55
+ let cursor = i + width;
56
+ while (cursor < text.length) {
57
+ const next = text.codePointAt(cursor);
58
+ const byte = variationSelectorToByte(next);
59
+ if (byte === null)
60
+ break;
61
+ bytes.push(byte);
62
+ cursor += next > 0xffff ? 2 : 1;
63
+ }
64
+ if (bytes.length < HEADER_BYTES || !MAGIC.every((value, index) => bytes[index] === value)) {
65
+ i += width;
66
+ continue;
67
+ }
68
+ const manifestLength = ((bytes[9] << 24) | (bytes[10] << 16) | (bytes[11] << 8) | bytes[12]) >>> 0;
69
+ found.push({
70
+ start_utf16: i,
71
+ end_utf16: cursor,
72
+ version: bytes[8],
73
+ manifest_length: manifestLength,
74
+ status: bytes.length >= HEADER_BYTES + manifestLength ? "ok" : "truncated",
75
+ });
76
+ i = cursor;
77
+ }
78
+ return found;
79
+ }
80
+ /** True when the span touches any detected credential. */
81
+ export const withinCredential = (credentials, span) => credentials.some((credential) => span.start_utf16 < credential.end_utf16 && span.end_utf16 > credential.start_utf16);
82
+ export const credentialNotice = (credentials) => {
83
+ const one = credentials.length === 1;
84
+ const truncated = credentials.some((credential) => credential.status === "truncated");
85
+ return `This draft carries ${one ? "a C2PA content credential" : `${credentials.length} C2PA content credentials`} embedded in the text itself (C2PA 2.3 §A.8, Unicode variation selectors)${truncated ? ", at least one of which is already incomplete" : ""}. The hidden characters listed below include ${one ? "its" : "their"} bytes. Removing them destroys the credential permanently, and it cannot be rebuilt from the visible text, so they are excluded from the automatic fix.`;
86
+ };