@liustack/pptfast 0.9.0 → 0.11.0

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.
package/dist/index.d.ts CHANGED
@@ -105,9 +105,9 @@ declare const PptxIRV3Schema: z.ZodObject<{
105
105
  animation: z.ZodOptional<z.ZodObject<{
106
106
  transition: z.ZodOptional<z.ZodEnum<{
107
107
  push: "push";
108
+ none: "none";
108
109
  fade: "fade";
109
110
  wipe: "wipe";
110
- none: "none";
111
111
  }>>;
112
112
  elements: z.ZodOptional<z.ZodEnum<{
113
113
  none: "none";
@@ -134,17 +134,17 @@ declare const PptxIRV3Schema: z.ZodObject<{
134
134
  seed: z.ZodOptional<z.ZodNumber>;
135
135
  slides: z.ZodArray<z.ZodObject<{
136
136
  type: z.ZodDefault<z.ZodEnum<{
137
+ content: "content";
137
138
  cover: "cover";
138
139
  chapter: "chapter";
139
- content: "content";
140
140
  ending: "ending";
141
141
  }>>;
142
142
  id: z.ZodOptional<z.ZodString>;
143
143
  placeholder: z.ZodOptional<z.ZodLiteral<true>>;
144
144
  layout: z.ZodOptional<z.ZodString>;
145
145
  beat: z.ZodOptional<z.ZodEnum<{
146
- anchor: "anchor";
147
146
  dense: "dense";
147
+ anchor: "anchor";
148
148
  breathing: "breathing";
149
149
  }>>;
150
150
  arrangement: z.ZodOptional<z.ZodEnum<{
@@ -343,8 +343,8 @@ declare const PptxIRV3Schema: z.ZodObject<{
343
343
  tag: z.ZodOptional<z.ZodString>;
344
344
  tone: z.ZodOptional<z.ZodEnum<{
345
345
  info: "info";
346
- accent: "accent";
347
346
  neutral: "neutral";
347
+ accent: "accent";
348
348
  }>>;
349
349
  }, z.core.$strict>>;
350
350
  }, z.core.$strict>, z.ZodObject<{
@@ -359,9 +359,9 @@ declare const PptxIRV3Schema: z.ZodObject<{
359
359
  type: z.ZodLiteral<"verdict_banner">;
360
360
  text: z.ZodString;
361
361
  tone: z.ZodEnum<{
362
- neutral: "neutral";
363
362
  positive: "positive";
364
363
  warning: "warning";
364
+ neutral: "neutral";
365
365
  }>;
366
366
  icon: z.ZodOptional<z.ZodEnum<{
367
367
  [x: string]: string;
@@ -378,8 +378,8 @@ declare const PptxIRV3Schema: z.ZodObject<{
378
378
  asset_id: z.ZodString;
379
379
  caption: z.ZodOptional<z.ZodString>;
380
380
  fit: z.ZodDefault<z.ZodEnum<{
381
- cover: "cover";
382
381
  contain: "contain";
382
+ cover: "cover";
383
383
  }>>;
384
384
  }, z.core.$strict>, z.ZodObject<{
385
385
  type: z.ZodLiteral<"image_grid">;
@@ -535,6 +535,25 @@ declare const PptxIRV3Schema: z.ZodObject<{
535
535
  to: z.ZodString;
536
536
  value: z.ZodNumber;
537
537
  }, z.core.$strict>>;
538
+ }, z.core.$strict>, z.ZodObject<{
539
+ type: z.ZodLiteral<"data_table">;
540
+ columns: z.ZodArray<z.ZodObject<{
541
+ key: z.ZodString;
542
+ label: z.ZodString;
543
+ align: z.ZodOptional<z.ZodEnum<{
544
+ left: "left";
545
+ right: "right";
546
+ center: "center";
547
+ }>>;
548
+ }, z.core.$strict>>;
549
+ rows: z.ZodArray<z.ZodObject<{
550
+ cells: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
551
+ emphasis: z.ZodOptional<z.ZodEnum<{
552
+ highlight: "highlight";
553
+ total: "total";
554
+ }>>;
555
+ }, z.core.$strict>>;
556
+ source: z.ZodOptional<z.ZodString>;
538
557
  }, z.core.$strict>], "type">>>;
539
558
  background: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
540
559
  kind: z.ZodLiteral<"color">;
@@ -556,8 +575,8 @@ declare const PptxIRV3Schema: z.ZodObject<{
556
575
  opacity: z.ZodNumber;
557
576
  }, z.core.$strict>>;
558
577
  fit: z.ZodOptional<z.ZodEnum<{
559
- cover: "cover";
560
578
  contain: "contain";
579
+ cover: "cover";
561
580
  }>>;
562
581
  }, z.core.$strict>], "kind">>;
563
582
  decor: z.ZodOptional<z.ZodObject<{
@@ -744,7 +763,7 @@ type ThemeRegistration = Omit<ThemeDefinition, "layouts"> & {
744
763
  * about to succeed).
745
764
  *
746
765
  * Once registered, the theme participates in `getInstalledThemeIds`,
747
- * `getThemeDefinition` (hence `effective-layout.ts`/`FullSlideSvg`'s
766
+ * `getThemeDefinition` (hence `layout-selection.ts`/`FullSlideSvg`'s
748
767
  * selection and `resolveBrand`), and `themes/index.ts`'s `resolveStyle` —
749
768
  * every internal theme lookup, with no separate "registered theme" branch
750
769
  * for callers to remember.
@@ -757,7 +776,7 @@ declare function getInstalledThemeIds(): readonly string[];
757
776
  * the builtin fallback (`THEME_DEFINITIONS[resolveThemeId(id)]`, which itself
758
777
  * folds an unrecognized id to consulting). The one lookup every internal
759
778
  * consumer that used to read `THEME_DEFINITIONS[resolveThemeId(id)]`
760
- * directly (`effective-layout.ts`, `FullSlideSvg.tsx`) now calls instead, so
779
+ * directly (`layout-selection.ts`, `full-slide-svg.tsx`) now calls instead, so
761
780
  * a registered theme's curated layouts actually drive selection end-to-end.
762
781
  */
763
782
  declare function getThemeDefinition(id: string): ThemeDefinition;
@@ -813,7 +832,7 @@ interface StrategyDefinition {
813
832
  * Content-archetype soft-weight set for W4's weighted layout selection
814
833
  * (spec §6 step 4: in-set candidates get `TENDENCY_WEIGHT` (×3), out-of-set
815
834
  * get the `BASE_WEIGHT` floor (×1) — both named constants live in
816
- * `svg/effective-layout.ts`, next to `resolveArchetypeId`, the sole
835
+ * `svg/layout-selection.ts`, next to `resolveArchetypeId`, the sole
817
836
  * consumer). Deliberately a separate field from {@link tendencies} above,
818
837
  * not a reinterpretation of it: `tendencies` mixes component-type names and
819
838
  * layout ids drawn from spec §5's strategy table verbatim and also feeds W5's
@@ -845,7 +864,7 @@ interface StrategyDefinition {
845
864
  * Cover/chapter/ending soft-weight sets (P1 variety wave, task 3 — "身份页
846
865
  * strategy 软加权"), one per identity slide type. Same ×3/×1 mechanics as
847
866
  * {@link layoutTendencies} (`TENDENCY_WEIGHT`/`BASE_WEIGHT`,
848
- * `svg/effective-layout.ts`) and the same consumer (`resolveArchetypeId`),
867
+ * `svg/layout-selection.ts`) and the same consumer (`resolveArchetypeId`),
849
868
  * just scoped to a disjoint id namespace: this field holds only
850
869
  * `LAYOUT_REGISTRY` cover/chapter/ending archetype ids
851
870
  * (`svg/layouts/registry.ts`'s `COVER_LAYOUTS`/`CHAPTER_LAYOUTS`/
@@ -899,7 +918,7 @@ interface PacingBudget {
899
918
  /**
900
919
  * Body-text baseline, in px, at 1280×720 slide geometry (spec §5 pacing
901
920
  * table's body-baseline column). Wired into rendering as of W4 task 3
902
- * (design decision 9): `src/svg/FullSlideSvg.tsx` resolves
921
+ * (design decision 9): `src/svg/full-slide-svg.tsx` resolves
903
922
  * `PACING_BUDGETS[resolveNarrative(ir.narrative).pacing].bodyBaselinePx`
904
923
  * once and passes it into `buildCtx`, which stores it as
905
924
  * `ComponentCtx.bodyFontPx` — the sole font-size input for the
@@ -985,11 +1004,26 @@ declare const DEFAULT_NARRATIVE: NarrativeProfile;
985
1004
  * happen to equal `DEFAULT_NARRATIVE`'s values because `general` *is* that
986
1005
  * exact combination, but the fallback here is per-axis, not "any omitted
987
1006
  * axis falls back to the whole default object")
1007
+ * - an `{id: <preset>}` object (T0b fix 2, scope-extended — see
1008
+ * {@link rescueIdShape}) → silently treated as that bare preset-id string,
1009
+ * the same weak-model shape slip `theme: {id: "consulting"}` invites by
1010
+ * analogy. Folded directly into this function's own entry (not just a
1011
+ * pre-parse pass two of its six call sites happen to run) so every caller
1012
+ * — `validateIr`, `validateSpec`, `layout-selection.ts`,
1013
+ * `full-slide-svg.tsx`, `ir-quality.ts`, `cli/commands.ts` alike — gets
1014
+ * this tolerance for free, present and future. Silent here (no rewrite
1015
+ * note — this is a pure resolver with no reporting channel, and adding one
1016
+ * would ripple into every one of those call sites' own return-shape
1017
+ * expectations); `validateIr`/`validateSpec` additionally run
1018
+ * {@link normalizeNarrativeShape} on their own raw pre-parse input first,
1019
+ * specifically to report + persist the rewrite into what they return.
988
1020
  *
989
1021
  * An unknown axis value, or an unknown key on the partial axes object,
990
1022
  * always throws {@link PptfastError} (never silently ignored or dropped) —
991
1023
  * omission and a typo are different intents, and only the former has a
992
- * reasonable default.
1024
+ * reasonable default. A mixed `{id, strategy}`-style shape is exactly this
1025
+ * case ({@link rescueIdShape} declines it): `id` is simply an unrecognized
1026
+ * key, so it hard-errors the same as any other typo.
993
1027
  *
994
1028
  * Renamed from `resolveScenario` (spec §8.1). Callers that still hold a
995
1029
  * pre-rename `mode`/`delivery` shaped input (e.g. a v3 IR's `scenario`
@@ -997,7 +1031,9 @@ declare const DEFAULT_NARRATIVE: NarrativeProfile;
997
1031
  * (`src/ir/migrate.ts`) for the deterministic field/value mapping. A
998
1032
  * v4-track document that still writes the old field/value spelling gets no
999
1033
  * such rescue (spec §16, reversing the now-superseded §15.4): this function
1000
- * hard-errors on it, same as any other unknown axis key or value.
1034
+ * hard-errors on it, same as any other unknown axis key or value. The
1035
+ * `{id}` shape above is not that — it was never a v3 `scenario` shape, just
1036
+ * a new confusion weak models invent by analogy to `theme.id`.
1001
1037
  */
1002
1038
  declare function resolveNarrative(input: string | Partial<NarrativeProfile> | undefined): NarrativeProfile;
1003
1039
 
@@ -1006,7 +1042,12 @@ declare function resolveNarrative(input: string | Partial<NarrativeProfile> | un
1006
1042
  * task 2 — renamed to "Deck Spec" per the vocabulary-v4 rename, spec §6/§8.1;
1007
1043
  * "spec §N" citations throughout this file that predate this rename still
1008
1044
  * point at that original W5 design doc, not this rename's own spec — left
1009
- * as historical citations, not renumbered).
1045
+ * as historical citations, not renumbered). That rename originally covered
1046
+ * the exported concepts and CLI vocabulary only — this module's own
1047
+ * directory stayed `src/plan` for a while after, a known stale name the
1048
+ * src domain-reorg wave's task T1c closed by moving it to `src/spec`
1049
+ * (mechanical `git mv`, every exported symbol unchanged): the path now
1050
+ * matches the vocabulary this header already used throughout.
1010
1051
  *
1011
1052
  * A deck spec is a workflow artifact, not a render prerequisite (spec §5's
1012
1053
  * "escape hatch": a bare IR v3 renders directly, `spec validate` is a
@@ -1049,15 +1090,15 @@ type PageBeat = (typeof BEAT_VALUES)[number];
1049
1090
  declare const PageSpecSchema: z.ZodObject<{
1050
1091
  id: z.ZodString;
1051
1092
  type: z.ZodEnum<{
1093
+ content: "content";
1052
1094
  cover: "cover";
1053
1095
  chapter: "chapter";
1054
- content: "content";
1055
1096
  ending: "ending";
1056
1097
  }>;
1057
1098
  heading: z.ZodString;
1058
1099
  beat: z.ZodOptional<z.ZodEnum<{
1059
- anchor: "anchor";
1060
1100
  dense: "dense";
1101
+ anchor: "anchor";
1061
1102
  breathing: "breathing";
1062
1103
  }>>;
1063
1104
  focus: z.ZodOptional<z.ZodString>;
@@ -1112,9 +1153,9 @@ declare const DeckSpecSchema: z.ZodObject<{
1112
1153
  animation: z.ZodOptional<z.ZodObject<{
1113
1154
  transition: z.ZodOptional<z.ZodEnum<{
1114
1155
  push: "push";
1156
+ none: "none";
1115
1157
  fade: "fade";
1116
1158
  wipe: "wipe";
1117
- none: "none";
1118
1159
  }>>;
1119
1160
  elements: z.ZodOptional<z.ZodEnum<{
1120
1161
  none: "none";
@@ -1134,15 +1175,15 @@ declare const DeckSpecSchema: z.ZodObject<{
1134
1175
  pages: z.ZodArray<z.ZodObject<{
1135
1176
  id: z.ZodString;
1136
1177
  type: z.ZodEnum<{
1178
+ content: "content";
1137
1179
  cover: "cover";
1138
1180
  chapter: "chapter";
1139
- content: "content";
1140
1181
  ending: "ending";
1141
1182
  }>;
1142
1183
  heading: z.ZodString;
1143
1184
  beat: z.ZodOptional<z.ZodEnum<{
1144
- anchor: "anchor";
1145
1185
  dense: "dense";
1186
+ anchor: "anchor";
1146
1187
  breathing: "breathing";
1147
1188
  }>>;
1148
1189
  focus: z.ZodOptional<z.ZodString>;
@@ -1165,6 +1206,19 @@ interface SpecValidateResult {
1165
1206
  ok: boolean;
1166
1207
  spec?: DeckSpec;
1167
1208
  errors: SpecValidationIssue[];
1209
+ /**
1210
+ * Same shape and channel as `ValidateResult.normalized` (`../validate-core.ts`)
1211
+ * — human-readable `path: alias → canonical`-style rewrite entries for every
1212
+ * deterministic pre-parse rewrite `validateSpec` applied before parsing.
1213
+ * Today the only source is `normalizeNarrativeShape` (`../narrative`, T0b
1214
+ * fix 2 scope extension): a top-level `narrative: {id: "<preset>"}` shape
1215
+ * rewritten to the bare preset string. Present only when at least one
1216
+ * rewrite happened; informational, never gates `ok` on its own.
1217
+ * `cli/commands.ts`'s `runSpecValidate` prints this through the same
1218
+ * `normalizedNote` helper `runValidate`/`runRender` already use for the
1219
+ * bare-IR path's own component field-alias notes.
1220
+ */
1221
+ normalized?: string[];
1168
1222
  }
1169
1223
  declare function formatSpecIssues(errors: SpecValidationIssue[]): string;
1170
1224
  /**
@@ -1203,6 +1257,20 @@ declare const SPEC_PAGE_COUNT_RANGE: Record<Pacing, {
1203
1257
  * resolves cleanly. Every spec-gate philosophy here is "hard block, no soft
1204
1258
  * warning" (spec §5's "escape hatch" section — a spec that doesn't fit this shape
1205
1259
  * should be authored as bare IR instead, not warned-and-shipped).
1260
+ *
1261
+ * Before the schema parse, {@link normalizeNarrativeShape} (`../narrative`,
1262
+ * T0b fix 2 scope extension) runs on the raw input, exactly mirroring
1263
+ * `validateIr`'s own pre-parse pass (`../validate-core.ts`) — a top-level
1264
+ * `narrative: {id: "<preset>"}` shape is rewritten to the bare preset
1265
+ * string before `DeckSpecSchema.safeParse` ever sees it, so the correction
1266
+ * lands in the returned `spec` itself (read again by `checkBeatRotation`/
1267
+ * `checkFocusVocabulary`/`checkPageCount` below, and by `runSpecValidate`'s
1268
+ * own OK-summary line, `../cli/commands.ts`), not just this function's own
1269
+ * local `resolveNarrative` call below. Every return path is wrapped in
1270
+ * `withNormalized` so the rewrite note (`SpecValidateResult.normalized`)
1271
+ * surfaces on success *or* failure, success or failure alike — same
1272
+ * "informational, never gates `ok`" contract `ValidateResult.normalized`
1273
+ * has.
1206
1274
  */
1207
1275
  declare function validateSpec(input: unknown): SpecValidateResult;
1208
1276
 
@@ -1311,7 +1379,7 @@ interface AssembleResult {
1311
1379
  * `narrative` field (renamed this task from `scenario`, spec §8.1's
1312
1380
  * `DeckPlan`→`DeckSpec` rename, task 2) carries across into the v4 IR's
1313
1381
  * own `narrative` field, its value already in the new strategy/pacing
1314
- * vocabulary (vocabulary-v4 rename, task 1 — `plan/index.ts`'s own
1382
+ * vocabulary (vocabulary-v4 rename, task 1 — `spec/index.ts`'s own
1315
1383
  * `resolveNarrative` call already validates it against that vocabulary
1316
1384
  * before this function ever runs).
1317
1385
  * `theme`/`filename`/`brand`/`meta`/`seed` (step 7) carry over from the spec when
@@ -1388,7 +1456,7 @@ declare function assembleDeck(spec: unknown, pages: Record<string, PageContent>)
1388
1456
  * Round-trip-safe despite the above, worth calling out because of that name
1389
1457
  * collision: the top-level `brand` field (`BrandSchema` — `logo_asset_id` /
1390
1458
  * `position`, the deck logo/position `BrandChrome` reads,
1391
- * `src/svg/BrandChrome.tsx`) is a plain passthrough on both sides
1459
+ * `src/svg/brand-chrome.tsx`) is a plain passthrough on both sides
1392
1460
  * ({@link assembleDeck} step 6 reads `spec.brand` into `ir.brand` — this
1393
1461
  * function reads `ir.brand` back into `spec.brand` below) — carried through
1394
1462
  * unmodified, same as `narrative`/`filename`/`seed`, never
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  resolveSpecThemeId,
13
13
  specJsonSchema,
14
14
  validateSpec
15
- } from "./chunk-RE53OS3K.js";
15
+ } from "./chunk-WCBFCHOZ.js";
16
16
  import {
17
17
  AUDIENCE_VALUES,
18
18
  BUILTIN_THEME_IDS,
@@ -40,7 +40,7 @@ import {
40
40
  resolveNarrative,
41
41
  styleJsonSchema,
42
42
  validateIr
43
- } from "./chunk-6JJGXB5S.js";
43
+ } from "./chunk-ONO7V2SF.js";
44
44
  import {
45
45
  installPlatform
46
46
  } from "./chunk-L524YK63.js";
@@ -5,7 +5,7 @@ import {
5
5
  blendOver,
6
6
  contrastRatio,
7
7
  renderSlideSvg
8
- } from "./chunk-6JJGXB5S.js";
8
+ } from "./chunk-ONO7V2SF.js";
9
9
  import {
10
10
  findRemoteAssetRef,
11
11
  getPlatform
@@ -215,4 +215,4 @@ export {
215
215
  runPixelContrastAudit,
216
216
  stripTextNodes
217
217
  };
218
- //# sourceMappingURL=pixel-audit-ZB4RSPHG.js.map
218
+ //# sourceMappingURL=pixel-audit-GA6B64W7.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/platform/browser.ts","../src/svg/audit/pixel-audit.ts"],"sourcesContent":["import { findRemoteAssetRef, type RasterizedImage } from \"./registry\"\n\n/**\n * Browser default for `rasterizeSvg` (audit-v2 phase B, spec §4.3/§11.8) —\n * native `Image` + `OffscreenCanvas`/`<canvas>` only, zero new dependency.\n * Applied at the pixel-audit call site (`../svg/audit/pixel-audit.ts`) the\n * same way `domParser`'s `?? globalThis.DOMParser` fallback already works\n * (`deck-audit.ts`'s `parseSvg`) — nothing calls `installPlatform()`\n * automatically in a browser, so this is a plain fallback function, not\n * something wired through `installPlatform()` itself. Lives outside\n * `src/platform/node.ts` (which imports `linkedom`/`sharp`) so it can sit\n * inside `src/index.ts`'s own browser-safe dependency closure without\n * pulling in either.\n *\n * Two explicit-failure paths, both load-bearing per the audit-v2 controller\n * ruling on browser remote assets (own tests: `browser.test.ts`):\n *\n * 1. `findRemoteAssetRef` — scanned *before* ever touching `Image`/canvas.\n * An `<img>`/`Image` load of an `http(s):` asset happens in a restricted\n * context: the resource is silently dropped rather than reliably\n * tainting the canvas, so without this guard a remote-image slide would\n * rasterize to a *blank* region and get sampled as if that blank were\n * the real background — exactly the \"checked nothing, reported clean\"\n * failure this wave rules out. (`node.ts`'s Sharp implementation shares\n * this same guard for a different reason — see `findRemoteAssetRef`'s own\n * doc comment.)\n * 2. `getImageData`'s own `SecurityError` — kept as a fallback for\n * whatever the markup scan didn't anticipate (a future asset kind, a\n * same-origin-but-still-tainting edge case): caught and re-thrown as an\n * explicit, readable error rather than left as a raw `DOMException` (or,\n * worse, silently producing zeroed pixel data some engines return\n * instead of throwing).\n */\n\ninterface Minimal2dContext {\n drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void\n getImageData(sx: number, sy: number, sw: number, sh: number): ImageData\n}\n\ninterface MinimalCanvas {\n getContext(id: \"2d\"): Minimal2dContext | null\n}\n\n/** `DOMException` is *not* an `Error` subclass per the WebIDL spec (real\n * browsers: `new DOMException(\"x\", \"SecurityError\") instanceof Error` is\n * `false`) — duck-type on `.name` rather than `instanceof Error`. */\nfunction isSecurityError(e: unknown): boolean {\n return typeof e === \"object\" && e !== null && (e as { name?: unknown }).name === \"SecurityError\"\n}\n\n/**\n * Maximum time (ms) to wait for `Image.onload`/`onerror` to fire before\n * treating the decode as stuck and rejecting explicitly, rather than\n * hanging the whole `rasterizeSvgInBrowser` call forever — unlike the\n * Node/Sharp path (`node.ts`'s `rasterizeSvg`, a synchronous, bounded\n * call), a browser `Image` decode has no timeout of its own, and a\n * genuinely stuck one (a browser/OS decoder bug, an unresponsive tab) would\n * otherwise leave the caller (e.g. a pixel-contrast audit) awaiting forever\n * with no way to know why. 30s: generous for even a large local decode —\n * this rasterizer only ever reaches `Image` for a local/data-URI SVG\n * (`findRemoteAssetRef` already rejected any remote asset reference above)\n * — but short enough that a stuck decode fails loud well within any\n * interactive caller's own patience.\n */\nexport const IMAGE_LOAD_TIMEOUT_MS = 30_000\n\nfunction loadImage(url: string): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => {\n reject(\n new Error(\n `rasterizeSvg: timed out after ${IMAGE_LOAD_TIMEOUT_MS}ms waiting for the browser to decode the rasterized SVG as an image (a stuck decode, never a silent hang)`,\n ),\n )\n }, IMAGE_LOAD_TIMEOUT_MS)\n const img = new Image()\n img.onload = () => {\n clearTimeout(timer)\n resolve(img)\n }\n img.onerror = () => {\n clearTimeout(timer)\n reject(new Error(\"rasterizeSvg: the browser could not decode the rasterized SVG as an image\"))\n }\n img.src = url\n })\n}\n\nfunction createCanvas(width: number, height: number): MinimalCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") {\n return new OffscreenCanvas(width, height) as unknown as MinimalCanvas\n }\n if (typeof document !== \"undefined\") {\n const canvas = document.createElement(\"canvas\")\n canvas.width = width\n canvas.height = height\n return canvas as unknown as MinimalCanvas\n }\n throw new Error(\n 'rasterizeSvg unavailable — in Node, call installNodePlatform() from \"@liustack/pptfast/node\" first (the pptfast CLI does this automatically); in a browser, OffscreenCanvas or a DOM canvas is required',\n )\n}\n\nexport async function rasterizeSvgInBrowser(svgMarkup: string, width: number, height: number): Promise<RasterizedImage> {\n const remoteRef = findRemoteAssetRef(svgMarkup)\n if (remoteRef) {\n throw new Error(\n `rasterizeSvg: refusing to rasterize an SVG that references a remote image (${remoteRef}) — only data-URI (or other local) assets are supported (a remote asset would silently drop when loaded this way, not rasterize)`,\n )\n }\n if (typeof Image === \"undefined\") {\n throw new Error(\n 'rasterizeSvg unavailable — in Node, call installNodePlatform() from \"@liustack/pptfast/node\" first (the pptfast CLI does this automatically); in a browser, the Image constructor is required',\n )\n }\n\n const blob = new Blob([svgMarkup], { type: \"image/svg+xml\" })\n const url = URL.createObjectURL(blob)\n try {\n const img = await loadImage(url)\n const canvas = createCanvas(width, height)\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) throw new Error(\"rasterizeSvg: could not obtain a 2d canvas context\")\n ctx.drawImage(img, 0, 0, width, height)\n let imageData: ImageData\n try {\n imageData = ctx.getImageData(0, 0, width, height)\n } catch (e) {\n if (isSecurityError(e)) {\n throw new Error(\n \"rasterizeSvg: the canvas was tainted while reading back pixel data (a cross-origin or otherwise untrusted asset) — only data-URI (or other local) assets are supported\",\n )\n }\n throw e\n }\n return { width: imageData.width, height: imageData.height, data: imageData.data }\n } finally {\n URL.revokeObjectURL(url)\n }\n}\n","import type { PptxIR } from \"@/ir\"\nimport { renderSlideSvg } from \"../../api\"\nimport { CANVAS_H_PX, CANVAS_W_PX } from \"../../constants\"\nimport { rasterizeSvgInBrowser } from \"../../platform/browser\"\nimport { getPlatform, type RasterizedImage } from \"../../platform/registry\"\nimport {\n __collectImageBackedTextRuns,\n blendOver,\n contrastRatio,\n type AuditFinding,\n type ImageBackedTextRun,\n} from \"./deck-audit\"\n\n/**\n * Optional pixel-level contrast audit (audit-v2 phase B, spec §4.3) — the\n * one pixel blind spot the deterministic SVG audit can't see: text painted\n * directly over a bare or too-faintly-scrimmed `<image>`, where\n * `deck-audit.ts`'s own SVG-color walk correctly gives up rather than guess\n * (`ImageBackedTextRun`, `__collectImageBackedTextRuns`). Never imported by\n * `deck-audit.ts` at the top level — `auditDeck`'s `pixels: true` branch\n * reaches this module through a lazy `import(\"./pixel-audit\")` instead\n * (that function's own doc comment explains why: this file statically\n * depends on `deck-audit.ts` for its shared primitives, so a *static*\n * import the other way would form a module cycle; nothing here needs to be\n * loaded at all for the far more common `pixels` left unset case).\n *\n * Flow per spec §4.3, steps 2-6:\n * 1. `__collectImageBackedTextRuns` — reuse the exact same background-\n * resolution walk the deterministic audit already ran, just reading its\n * \"could not resolve\" runs instead of discarding them. A page with none\n * skips rasterization entirely (no rasterizer call, no risk of that\n * page's own remote-asset guard ever firing) — real cost only where\n * there is real work to do.\n * 2. `stripTextNodes` — the \"去文字克隆\" clone.\n * 3. `rasterizeSvg(stripped, 1280, 720)` — platform primitive (Sharp in\n * Node, native canvas in a browser — see `resolveRasterizer`).\n * 4/5. `worstCaseSample` — grid-sample each run's estimated box against the\n * rasterized pixels, tracking the least-favorable (lowest-ratio) point.\n * 6. Only a sample below `PIXEL_HARD_FINDING_MAX_RATIO` becomes a finding —\n * spec §4.3's own anti-false-positive gate, see that constant's doc\n * comment.\n */\n\nconst PIXEL_CANVAS_W = CANVAS_W_PX\nconst PIXEL_CANVAS_H = CANVAS_H_PX\n\n/**\n * Below this, a pixel-sampled contrast finding is emitted regardless of the\n * real WCAG target (`ImageBackedTextRun.required`) — spec §4.3's own \"为了\n * 控制误报,首版只有低于 1.5:1 才进入 hard finding\" gate. Pixel sampling\n * carries antialiasing/rasterizer noise the SVG-only walk never has to deal\n * with (spec §11.10's determinism footnote: no cross-platform byte\n * guarantee for this layer) — `node-rasterize.test.ts`'s own transparency\n * probe found up to ~1/255-per-channel sequential-blend rounding drift,\n * nowhere near enough to move a real ratio across this floor. Deliberately\n * far below either real WCAG floor (3/4.5) so only a genuinely broken\n * pairing fires, not a borderline-but-fine one.\n */\nconst PIXEL_HARD_FINDING_MAX_RATIO = 1.5\n\n/**\n * Strip every `<text>...</text>` (and self-closing `<text/>`) element from\n * SVG markup — spec §4.3 step 2's \"去文字克隆\" (clone with text removed). A\n * plain string replace, not a DOM parse/remove/reserialize round-trip:\n * removing an element has zero effect on sibling/ancestor geometry in SVG\n * (no reflow, unlike HTML), so a round-trip would only add risk — a\n * serializer (linkedom in Node) producing markup that doesn't byte-match\n * what a real browser's `XMLSerializer` would, for zero benefit. Sound\n * under two preconditions this renderer's own architecture already\n * guarantees: `renderToStaticMarkup` (React) HTML/XML-escapes every text\n * *node*, so a literal `</text>` substring appearing in `markup` can only\n * ever be a real closing tag, never escaped slide content; and `<text>` is\n * never nested inside another `<text>` anywhere in `src/svg` (verified\n * across every emitter while building this task).\n */\nconst TEXT_ELEMENT_RE = /<text\\b[^>]*\\/>|<text\\b[^>]*>[\\s\\S]*?<\\/text>/g\n\nexport function stripTextNodes(markup: string): string {\n return markup.replace(TEXT_ELEMENT_RE, \"\")\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n // Rounds first: callers now include averaged (non-integer) channel values\n // (`averageWindow`'s own doc comment) alongside the original raw-integer\n // ones — `Math.round` is a no-op for the latter, so this stays byte-for-\n // byte compatible with every pre-existing call shape.\n const toHex = (v: number) => Math.round(v).toString(16).padStart(2, \"0\")\n return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()\n}\n\n/**\n * Ascent/descent as a fraction of `fontSize`, defining the vertical band\n * `worstCaseSample` grids over — no real font metrics are available at\n * audit time (this renderer never embeds/queries a font file), so both are\n * the same kind of declared-size-relative heuristic `deck-audit.ts`'s own\n * `TEXT_DESCENT_RATIO` (0.25, used for v-overflow/derived-box-height) is —\n * `SAMPLE_DESCENT_RATIO` mirrors it exactly for consistency between the two\n * auditors' notion of \"how far below the baseline a glyph's ink extends\".\n * `SAMPLE_ASCENT_RATIO` (0.75) is this task's own addition — no existing\n * constant to mirror since nothing before this needed a text run's *top*\n * edge — a standard approximation for common UI sans-serif cap-height/\n * ascender proportion.\n */\nconst SAMPLE_ASCENT_RATIO = 0.75\nconst SAMPLE_DESCENT_RATIO = 0.25\n\n/**\n * Minimum contiguous low-contrast patch size (px, both axes, at the\n * 1280×720 rasterization scale) the sampling design below guarantees never\n * to miss — \"glyph scale\": at or below the narrowest highlight/shadow\n * sliver a single character can plausibly introduce (the deep-acceptance\n * review's own demonstrated miss used a 24px-wide patch, comfortably above\n * this floor with margin to spare). Anchors both `SAMPLE_STRIDE_PX`\n * (coverage) and the `SAMPLE_STRIDE_PX` vs `AGGREGATION_HALF_PX` margin\n * (noise robustness) — see `worstCaseSample`'s own doc comment for the\n * covering argument these three constants together satisfy.\n */\nconst MIN_GUARANTEED_PATCH_PX = 10\n\n/**\n * Sample-center spacing (px), both axes — half of `MIN_GUARANTEED_PATCH_PX`,\n * computed rather than duplicated as a literal so the two constants can\n * never drift apart. The standard \"sample at <= half the feature size\"\n * covering rule. Replaces\n * the old fixed 5×3-point grid (`SAMPLE_COLS`/`SAMPLE_ROWS`), whose own\n * justifying comment (\"15 points is already enough...\") the deep-acceptance\n * review falsified with a hand-verified repro: a real 1.03:1 contrast patch\n * sitting 35px from the nearest sample column (columns were 70px apart at\n * ImageCoverPage's real org-line scale) produced zero findings, yet the\n * identical patch was caught the moment it landed on a column — pure\n * alignment luck, not a real \"worst-case band\" guarantee. A dense,\n * position-independent stride closes that gap entirely (see the coverage\n * proof below) instead of shrinking it.\n */\nconst SAMPLE_STRIDE_PX = MIN_GUARANTEED_PATCH_PX / 2\n\n/**\n * Half-width (px) of the small square window averaged at each sample\n * center before the worst-case comparison — the noise-robustness half of\n * this design. A lone single-pixel outlier (rasterizer antialiasing noise\n * under a glyph edge, photo grain) is diluted to at most 1/9 of a 3×3\n * window's mean, never enough on its own to pull a genuinely-safe\n * surrounding patch's averaged ratio under the 1.5 hard-finding floor (this\n * file's own test: a single near-black (8) pixel inside an otherwise-232\n * 3×3 window averages to ~207.5, nowhere near dark enough to fail near-\n * black text). Small enough — window width 3, well under `SAMPLE_STRIDE_PX\n * × 2 = 10` — that it cannot itself straddle a genuine\n * `MIN_GUARANTEED_PATCH_PX`-wide bad patch and a safe neighbor closely\n * enough to dilute a real defect back above the floor (this file's own\n * test: a real 12px-wide dark patch at the same spot the noise test uses\n * still produces a finding).\n */\nconst AGGREGATION_HALF_PX = 1\n\ninterface WorstCaseSample {\n ratio: number\n background: string\n}\n\n/**\n * Dense sample-center positions from `lo` to `hi` inclusive, `SAMPLE_STRIDE_PX`\n * apart. Always includes both endpoints, even when the span isn't an exact\n * stride multiple (the last gap can be shorter than the stride, never\n * longer) — a run's own edges are never under-sampled relative to its\n * interior. `[lo]` when the span has collapsed to (or below) a point, same\n * degenerate-input guard the old fixed-fraction grid had.\n */\nfunction samplePositions(lo: number, hi: number): number[] {\n if (hi <= lo) return [lo]\n const positions: number[] = []\n for (let v = lo; v < hi; v += SAMPLE_STRIDE_PX) positions.push(v)\n positions.push(hi)\n return positions\n}\n\n/**\n * Mean RGB of the `(2*AGGREGATION_HALF_PX+1)²` pixel block centered at\n * `(cx, cy)` (rounded to the nearest pixel) — the noise-robustness\n * aggregation step, see `AGGREGATION_HALF_PX`'s own doc comment. Skips any\n * pixel that's off-canvas or below full alpha, same indeterminate rule the\n * original single-point design used; a window with at least one usable\n * pixel still contributes its partial average rather than being discarded\n * outright, maximizing real coverage near a run's own box edges. Returns\n * `null` only when the window contained zero usable pixels.\n */\nfunction averageWindow(image: RasterizedImage, cx: number, cy: number): { r: number; g: number; b: number } | null {\n const x0 = Math.round(cx)\n const y0 = Math.round(cy)\n let sumR = 0\n let sumG = 0\n let sumB = 0\n let count = 0\n for (let dy = -AGGREGATION_HALF_PX; dy <= AGGREGATION_HALF_PX; dy++) {\n const y = y0 + dy\n if (y < 0 || y >= image.height) continue\n for (let dx = -AGGREGATION_HALF_PX; dx <= AGGREGATION_HALF_PX; dx++) {\n const x = x0 + dx\n if (x < 0 || x >= image.width) continue\n const i = (y * image.width + x) * 4\n const alpha = image.data[i + 3]!\n if (alpha < 255) continue\n sumR += image.data[i]!\n sumG += image.data[i + 1]!\n sumB += image.data[i + 2]!\n count++\n }\n }\n if (count === 0) return null\n return { r: sumR / count, g: sumG / count, b: sumB / count }\n}\n\n/**\n * Grid-sample `run`'s estimated box against `image` at a dense,\n * deterministic stride (`SAMPLE_STRIDE_PX`) — each sample point itself a\n * small-window average (`averageWindow`/`AGGREGATION_HALF_PX`) rather than\n * one raw pixel — tracking the least-favorable (lowest) contrast ratio\n * found overall: spec §4.3 step 6's \"WCAG 最不利带\" (worst-case band).\n *\n * **Coverage guarantee:** any axis-aligned contiguous low-contrast patch at\n * least `MIN_GUARANTEED_PATCH_PX` (10px) wide *and* tall is always fully\n * covered by at least one sample's aggregation window, regardless of the\n * patch's position relative to the grid — no alignment/phase assumption,\n * unlike the fixed grid this replaces. Proof, one axis at a time: sample\n * centers sit at a fixed stride S=5px; a window centered at `c` is fully\n * inside a patch spanning `[a, a+10]` exactly when\n * `c ∈ [a+1, a+9]` (window half-width 1px each side) — an interval of\n * length 8 >= S, so by the covering property of a grid spaced S apart (any\n * interval of length >= S must contain a grid point, since consecutive\n * centers are only S apart), that interval always contains at least one\n * sample center. The same argument applies independently on the\n * perpendicular axis, so a 2D patch of that minimum size is always hit on\n * both axes at once. (The deep-acceptance review's demonstrated miss used a\n * 24px patch — comfortably inside this guarantee with margin to spare.)\n *\n * **Noise robustness:** see `AGGREGATION_HALF_PX`'s own doc comment — the\n * same small window that provides the guarantee above also means a single\n * noisy pixel can't flip a genuinely-safe patch into a false finding.\n *\n * Returns `null` when *no* sample window anywhere yielded any usable pixel\n * at all (every window skipped, or the run's box fell entirely outside the\n * rasterized canvas) — the caller treats that as \"nothing proven either\n * way\", not a finding.\n */\nfunction worstCaseSample(run: ImageBackedTextRun, image: RasterizedImage): WorstCaseSample | null {\n const top = run.baseline - run.fontSize * SAMPLE_ASCENT_RATIO\n const bottom = run.baseline + run.fontSize * SAMPLE_DESCENT_RATIO\n let worst: WorstCaseSample | null = null\n\n const ys = samplePositions(top, bottom)\n const xs = samplePositions(run.left, run.right)\n\n for (const y of ys) {\n for (const x of xs) {\n const avg = averageWindow(image, x, y)\n if (!avg) continue\n\n const bgHex = rgbToHex(avg.r, avg.g, avg.b)\n const effective = blendOver(run.fill, bgHex, run.alpha)\n const ratio = contrastRatio(effective, bgHex)\n if (!worst || ratio < worst.ratio) worst = { ratio, background: bgHex }\n }\n }\n return worst\n}\n\nexport interface PixelContrastIssue {\n text: string\n fill: string\n /** The small-window-averaged RGB (hex) at the worst-case sample center —\n * a rasterized-and-aggregated sample, never a resolved SVG paint (see\n * `averageWindow`). */\n background: string\n ratio: number\n required: number\n fontSize: number\n}\n\nfunction pixelContrastMessage(issue: PixelContrastIssue): string {\n return (\n `text \"${issue.text}\" has a pixel-sampled contrast ratio of ${issue.ratio.toFixed(2)}:1 against its image background ` +\n `(sampled ${issue.background}, target ${issue.required}:1) — this text sits directly on an image with no resolvable ` +\n `solid backing color; reposition it, add an opaque-enough scrim behind it, or recolor the text`\n )\n}\n\ntype Rasterizer = (svgMarkup: string, width: number, height: number) => Promise<RasterizedImage>\n\n/**\n * `getPlatform().rasterizeSvg` (Sharp once `installNodePlatform()` ran) or\n * the browser default — the exact same `?? fallback` shape `deck-audit.ts`'s\n * `parseSvg` already uses for `domParser`. Never throws itself: an\n * environment with neither capability surfaces through\n * `rasterizeSvgInBrowser`'s own curated \"rasterizeSvg unavailable\" error the\n * first time the returned function is actually called (its `Image`/canvas\n * capability guards — see `browser.ts`), which is the explicit-failure\n * contract spec §11.7's \"契约层\" asks for either way.\n */\nfunction resolveRasterizer(): Rasterizer {\n return getPlatform().rasterizeSvg ?? rasterizeSvgInBrowser\n}\n\nasync function pixelFindingsForPage(\n markup: string,\n page: number,\n slideId: string | undefined,\n rasterize: Rasterizer,\n): Promise<AuditFinding[]> {\n const runs = __collectImageBackedTextRuns(markup)\n if (runs.length === 0) return []\n\n const stripped = stripTextNodes(markup)\n const image = await rasterize(stripped, PIXEL_CANVAS_W, PIXEL_CANVAS_H)\n\n const findings: AuditFinding[] = []\n for (const run of runs) {\n const sampled = worstCaseSample(run, image)\n if (!sampled) continue\n if (sampled.ratio < PIXEL_HARD_FINDING_MAX_RATIO) {\n const issue: PixelContrastIssue = {\n text: run.text,\n fill: run.fill,\n background: sampled.background,\n ratio: sampled.ratio,\n required: run.required,\n fontSize: run.fontSize,\n }\n findings.push({\n page,\n ...(slideId !== undefined ? { slideId } : {}),\n code: \"low-contrast\",\n message: pixelContrastMessage(issue),\n // `source: \"pixels\"` distinguishes this from an SVG-color-resolved\n // low-contrast finding's own `detail` shape (`ContrastIssue`,\n // `deck-audit.ts`) — same `code`, since both are the same category\n // of defect (text fails contrast against its real background), just\n // measured through a different, more reliable source for that\n // background's color.\n detail: { ...issue, source: \"pixels\" },\n })\n }\n }\n return findings\n}\n\n/**\n * Test-only re-export (`__`-prefixed, same \"SDK-internal, not part of any\n * public barrel\" convention `deck-audit.ts`'s own `__collectBgRegions`/\n * `__pathBoundingBox` establish): lets `pixel-audit.test.ts` exercise the\n * sampling-grid + `PIXEL_HARD_FINDING_MAX_RATIO` threshold logic directly,\n * with a hand-crafted `rasterize` function returning exact, controlled\n * pixel data — real component geometry (`ImagePages.tsx`'s `DarkScrim`, in\n * particular) turned out unable to organically produce a sub-1.5 ratio for\n * *any* photo brightness at the org-line's single-scrim-layer position\n * (confirmed empirically while building this task's own test suite: even a\n * pure-white source image only reaches ~1.83), which is the calibration\n * working as designed (spec §4.3's own \"control false positives\" gate) but\n * makes the threshold-crossing branch unreachable through real IR/archetype\n * fixtures alone.\n */\nexport const __pixelFindingsForPage = pixelFindingsForPage\n\n/**\n * The pixel-audit pass over an already-valid deck — `auditDeck(ir, {pixels:\n * true})`'s own async branch (`deck-audit.ts`). Independently walks\n * `ir.slides` (skipping placeholders the same way `runDeterministicAudit`\n * does) rather than reusing that function's own loop, so this module has no\n * static dependency back on it beyond the few named imports at the top of\n * this file — `renderSlideSvg` is pure and synchronous, so re-rendering\n * each non-placeholder slide a second time costs a little CPU, never\n * correctness (spec's own \"no second renderer\" non-goal is about a\n * *different* rendering path, not calling the one true renderer twice).\n *\n * Sequential, not `Promise.all` — deliberately bounds rasterization\n * concurrency to 1 (each Sharp call is real CPU-bound work) and makes the\n * page-order-stable output an obvious property of the code rather than an\n * incidental one `Promise.all`'s array-order guarantee happens to provide.\n *\n * A rasterization failure on any one page (missing platform capability, a\n * remote asset reference, a tainted canvas) propagates out of this function\n * entirely, aborting the whole pixel pass rather than silently skipping\n * just that page — spec §11.7's \"契约层\": a requested-but-failed pixel audit\n * is an explicit failure, never a partial \"clean\".\n */\nexport async function runPixelContrastAudit(ir: PptxIR): Promise<AuditFinding[]> {\n const rasterize = resolveRasterizer()\n const findings: AuditFinding[] = []\n for (let i = 0; i < ir.slides.length; i++) {\n const slide = ir.slides[i]!\n if (slide.placeholder) continue\n const page = i + 1\n const slideId = slide.id\n const markup = renderSlideSvg(ir, i)\n findings.push(...(await pixelFindingsForPage(markup, page, slideId, rasterize)))\n }\n return findings\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/platform/browser.ts","../src/svg/audit/pixel-audit.ts"],"sourcesContent":["import { findRemoteAssetRef, type RasterizedImage } from \"./registry\"\n\n/**\n * Browser default for `rasterizeSvg` (audit-v2 phase B, spec §4.3/§11.8) —\n * native `Image` + `OffscreenCanvas`/`<canvas>` only, zero new dependency.\n * Applied at the pixel-audit call site (`../svg/audit/pixel-audit.ts`) the\n * same way `domParser`'s `?? globalThis.DOMParser` fallback already works\n * (`deck-audit.ts`'s `parseSvg`) — nothing calls `installPlatform()`\n * automatically in a browser, so this is a plain fallback function, not\n * something wired through `installPlatform()` itself. Lives outside\n * `src/platform/node.ts` (which imports `linkedom`/`sharp`) so it can sit\n * inside `src/index.ts`'s own browser-safe dependency closure without\n * pulling in either.\n *\n * Two explicit-failure paths, both load-bearing per the audit-v2 controller\n * ruling on browser remote assets (own tests: `browser.test.ts`):\n *\n * 1. `findRemoteAssetRef` — scanned *before* ever touching `Image`/canvas.\n * An `<img>`/`Image` load of an `http(s):` asset happens in a restricted\n * context: the resource is silently dropped rather than reliably\n * tainting the canvas, so without this guard a remote-image slide would\n * rasterize to a *blank* region and get sampled as if that blank were\n * the real background — exactly the \"checked nothing, reported clean\"\n * failure this wave rules out. (`node.ts`'s Sharp implementation shares\n * this same guard for a different reason — see `findRemoteAssetRef`'s own\n * doc comment.)\n * 2. `getImageData`'s own `SecurityError` — kept as a fallback for\n * whatever the markup scan didn't anticipate (a future asset kind, a\n * same-origin-but-still-tainting edge case): caught and re-thrown as an\n * explicit, readable error rather than left as a raw `DOMException` (or,\n * worse, silently producing zeroed pixel data some engines return\n * instead of throwing).\n */\n\ninterface Minimal2dContext {\n drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void\n getImageData(sx: number, sy: number, sw: number, sh: number): ImageData\n}\n\ninterface MinimalCanvas {\n getContext(id: \"2d\"): Minimal2dContext | null\n}\n\n/** `DOMException` is *not* an `Error` subclass per the WebIDL spec (real\n * browsers: `new DOMException(\"x\", \"SecurityError\") instanceof Error` is\n * `false`) — duck-type on `.name` rather than `instanceof Error`. */\nfunction isSecurityError(e: unknown): boolean {\n return typeof e === \"object\" && e !== null && (e as { name?: unknown }).name === \"SecurityError\"\n}\n\n/**\n * Maximum time (ms) to wait for `Image.onload`/`onerror` to fire before\n * treating the decode as stuck and rejecting explicitly, rather than\n * hanging the whole `rasterizeSvgInBrowser` call forever — unlike the\n * Node/Sharp path (`node.ts`'s `rasterizeSvg`, a synchronous, bounded\n * call), a browser `Image` decode has no timeout of its own, and a\n * genuinely stuck one (a browser/OS decoder bug, an unresponsive tab) would\n * otherwise leave the caller (e.g. a pixel-contrast audit) awaiting forever\n * with no way to know why. 30s: generous for even a large local decode —\n * this rasterizer only ever reaches `Image` for a local/data-URI SVG\n * (`findRemoteAssetRef` already rejected any remote asset reference above)\n * — but short enough that a stuck decode fails loud well within any\n * interactive caller's own patience.\n */\nexport const IMAGE_LOAD_TIMEOUT_MS = 30_000\n\nfunction loadImage(url: string): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => {\n reject(\n new Error(\n `rasterizeSvg: timed out after ${IMAGE_LOAD_TIMEOUT_MS}ms waiting for the browser to decode the rasterized SVG as an image (a stuck decode, never a silent hang)`,\n ),\n )\n }, IMAGE_LOAD_TIMEOUT_MS)\n const img = new Image()\n img.onload = () => {\n clearTimeout(timer)\n resolve(img)\n }\n img.onerror = () => {\n clearTimeout(timer)\n reject(new Error(\"rasterizeSvg: the browser could not decode the rasterized SVG as an image\"))\n }\n img.src = url\n })\n}\n\nfunction createCanvas(width: number, height: number): MinimalCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") {\n return new OffscreenCanvas(width, height) as unknown as MinimalCanvas\n }\n if (typeof document !== \"undefined\") {\n const canvas = document.createElement(\"canvas\")\n canvas.width = width\n canvas.height = height\n return canvas as unknown as MinimalCanvas\n }\n throw new Error(\n 'rasterizeSvg unavailable — in Node, call installNodePlatform() from \"@liustack/pptfast/node\" first (the pptfast CLI does this automatically); in a browser, OffscreenCanvas or a DOM canvas is required',\n )\n}\n\nexport async function rasterizeSvgInBrowser(svgMarkup: string, width: number, height: number): Promise<RasterizedImage> {\n const remoteRef = findRemoteAssetRef(svgMarkup)\n if (remoteRef) {\n throw new Error(\n `rasterizeSvg: refusing to rasterize an SVG that references a remote image (${remoteRef}) — only data-URI (or other local) assets are supported (a remote asset would silently drop when loaded this way, not rasterize)`,\n )\n }\n if (typeof Image === \"undefined\") {\n throw new Error(\n 'rasterizeSvg unavailable — in Node, call installNodePlatform() from \"@liustack/pptfast/node\" first (the pptfast CLI does this automatically); in a browser, the Image constructor is required',\n )\n }\n\n const blob = new Blob([svgMarkup], { type: \"image/svg+xml\" })\n const url = URL.createObjectURL(blob)\n try {\n const img = await loadImage(url)\n const canvas = createCanvas(width, height)\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) throw new Error(\"rasterizeSvg: could not obtain a 2d canvas context\")\n ctx.drawImage(img, 0, 0, width, height)\n let imageData: ImageData\n try {\n imageData = ctx.getImageData(0, 0, width, height)\n } catch (e) {\n if (isSecurityError(e)) {\n throw new Error(\n \"rasterizeSvg: the canvas was tainted while reading back pixel data (a cross-origin or otherwise untrusted asset) — only data-URI (or other local) assets are supported\",\n )\n }\n throw e\n }\n return { width: imageData.width, height: imageData.height, data: imageData.data }\n } finally {\n URL.revokeObjectURL(url)\n }\n}\n","import type { PptxIR } from \"@/ir\"\nimport { renderSlideSvg } from \"../../api\"\nimport { CANVAS_H_PX, CANVAS_W_PX } from \"../../constants\"\nimport { rasterizeSvgInBrowser } from \"../../platform/browser\"\nimport { getPlatform, type RasterizedImage } from \"../../platform/registry\"\nimport {\n __collectImageBackedTextRuns,\n blendOver,\n contrastRatio,\n type AuditFinding,\n type ImageBackedTextRun,\n} from \"./deck-audit\"\n\n/**\n * Optional pixel-level contrast audit (audit-v2 phase B, spec §4.3) — the\n * one pixel blind spot the deterministic SVG audit can't see: text painted\n * directly over a bare or too-faintly-scrimmed `<image>`, where\n * `deck-audit.ts`'s own SVG-color walk correctly gives up rather than guess\n * (`ImageBackedTextRun`, `__collectImageBackedTextRuns`). Never imported by\n * `deck-audit.ts` at the top level — `auditDeck`'s `pixels: true` branch\n * reaches this module through a lazy `import(\"./pixel-audit\")` instead\n * (that function's own doc comment explains why: this file statically\n * depends on `deck-audit.ts` for its shared primitives, so a *static*\n * import the other way would form a module cycle; nothing here needs to be\n * loaded at all for the far more common `pixels` left unset case).\n *\n * Flow per spec §4.3, steps 2-6:\n * 1. `__collectImageBackedTextRuns` — reuse the exact same background-\n * resolution walk the deterministic audit already ran, just reading its\n * \"could not resolve\" runs instead of discarding them. A page with none\n * skips rasterization entirely (no rasterizer call, no risk of that\n * page's own remote-asset guard ever firing) — real cost only where\n * there is real work to do.\n * 2. `stripTextNodes` — the \"去文字克隆\" clone.\n * 3. `rasterizeSvg(stripped, 1280, 720)` — platform primitive (Sharp in\n * Node, native canvas in a browser — see `resolveRasterizer`).\n * 4/5. `worstCaseSample` — grid-sample each run's estimated box against the\n * rasterized pixels, tracking the least-favorable (lowest-ratio) point.\n * 6. Only a sample below `PIXEL_HARD_FINDING_MAX_RATIO` becomes a finding —\n * spec §4.3's own anti-false-positive gate, see that constant's doc\n * comment.\n */\n\nconst PIXEL_CANVAS_W = CANVAS_W_PX\nconst PIXEL_CANVAS_H = CANVAS_H_PX\n\n/**\n * Below this, a pixel-sampled contrast finding is emitted regardless of the\n * real WCAG target (`ImageBackedTextRun.required`) — spec §4.3's own \"为了\n * 控制误报,首版只有低于 1.5:1 才进入 hard finding\" gate. Pixel sampling\n * carries antialiasing/rasterizer noise the SVG-only walk never has to deal\n * with (spec §11.10's determinism footnote: no cross-platform byte\n * guarantee for this layer) — `node-rasterize.test.ts`'s own transparency\n * probe found up to ~1/255-per-channel sequential-blend rounding drift,\n * nowhere near enough to move a real ratio across this floor. Deliberately\n * far below either real WCAG floor (3/4.5) so only a genuinely broken\n * pairing fires, not a borderline-but-fine one.\n */\nconst PIXEL_HARD_FINDING_MAX_RATIO = 1.5\n\n/**\n * Strip every `<text>...</text>` (and self-closing `<text/>`) element from\n * SVG markup — spec §4.3 step 2's \"去文字克隆\" (clone with text removed). A\n * plain string replace, not a DOM parse/remove/reserialize round-trip:\n * removing an element has zero effect on sibling/ancestor geometry in SVG\n * (no reflow, unlike HTML), so a round-trip would only add risk — a\n * serializer (linkedom in Node) producing markup that doesn't byte-match\n * what a real browser's `XMLSerializer` would, for zero benefit. Sound\n * under two preconditions this renderer's own architecture already\n * guarantees: `renderToStaticMarkup` (React) HTML/XML-escapes every text\n * *node*, so a literal `</text>` substring appearing in `markup` can only\n * ever be a real closing tag, never escaped slide content; and `<text>` is\n * never nested inside another `<text>` anywhere in `src/svg` (verified\n * across every emitter while building this task).\n */\nconst TEXT_ELEMENT_RE = /<text\\b[^>]*\\/>|<text\\b[^>]*>[\\s\\S]*?<\\/text>/g\n\nexport function stripTextNodes(markup: string): string {\n return markup.replace(TEXT_ELEMENT_RE, \"\")\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n // Rounds first: callers now include averaged (non-integer) channel values\n // (`averageWindow`'s own doc comment) alongside the original raw-integer\n // ones — `Math.round` is a no-op for the latter, so this stays byte-for-\n // byte compatible with every pre-existing call shape.\n const toHex = (v: number) => Math.round(v).toString(16).padStart(2, \"0\")\n return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()\n}\n\n/**\n * Ascent/descent as a fraction of `fontSize`, defining the vertical band\n * `worstCaseSample` grids over — no real font metrics are available at\n * audit time (this renderer never embeds/queries a font file), so both are\n * the same kind of declared-size-relative heuristic `deck-audit.ts`'s own\n * `TEXT_DESCENT_RATIO` (0.25, used for v-overflow/derived-box-height) is —\n * `SAMPLE_DESCENT_RATIO` mirrors it exactly for consistency between the two\n * auditors' notion of \"how far below the baseline a glyph's ink extends\".\n * `SAMPLE_ASCENT_RATIO` (0.75) is this task's own addition — no existing\n * constant to mirror since nothing before this needed a text run's *top*\n * edge — a standard approximation for common UI sans-serif cap-height/\n * ascender proportion.\n */\nconst SAMPLE_ASCENT_RATIO = 0.75\nconst SAMPLE_DESCENT_RATIO = 0.25\n\n/**\n * Minimum contiguous low-contrast patch size (px, both axes, at the\n * 1280×720 rasterization scale) the sampling design below guarantees never\n * to miss — \"glyph scale\": at or below the narrowest highlight/shadow\n * sliver a single character can plausibly introduce (the deep-acceptance\n * review's own demonstrated miss used a 24px-wide patch, comfortably above\n * this floor with margin to spare). Anchors both `SAMPLE_STRIDE_PX`\n * (coverage) and the `SAMPLE_STRIDE_PX` vs `AGGREGATION_HALF_PX` margin\n * (noise robustness) — see `worstCaseSample`'s own doc comment for the\n * covering argument these three constants together satisfy.\n */\nconst MIN_GUARANTEED_PATCH_PX = 10\n\n/**\n * Sample-center spacing (px), both axes — half of `MIN_GUARANTEED_PATCH_PX`,\n * computed rather than duplicated as a literal so the two constants can\n * never drift apart. The standard \"sample at <= half the feature size\"\n * covering rule. Replaces\n * the old fixed 5×3-point grid (`SAMPLE_COLS`/`SAMPLE_ROWS`), whose own\n * justifying comment (\"15 points is already enough...\") the deep-acceptance\n * review falsified with a hand-verified repro: a real 1.03:1 contrast patch\n * sitting 35px from the nearest sample column (columns were 70px apart at\n * ImageCoverPage's real org-line scale) produced zero findings, yet the\n * identical patch was caught the moment it landed on a column — pure\n * alignment luck, not a real \"worst-case band\" guarantee. A dense,\n * position-independent stride closes that gap entirely (see the coverage\n * proof below) instead of shrinking it.\n */\nconst SAMPLE_STRIDE_PX = MIN_GUARANTEED_PATCH_PX / 2\n\n/**\n * Half-width (px) of the small square window averaged at each sample\n * center before the worst-case comparison — the noise-robustness half of\n * this design. A lone single-pixel outlier (rasterizer antialiasing noise\n * under a glyph edge, photo grain) is diluted to at most 1/9 of a 3×3\n * window's mean, never enough on its own to pull a genuinely-safe\n * surrounding patch's averaged ratio under the 1.5 hard-finding floor (this\n * file's own test: a single near-black (8) pixel inside an otherwise-232\n * 3×3 window averages to ~207.5, nowhere near dark enough to fail near-\n * black text). Small enough — window width 3, well under `SAMPLE_STRIDE_PX\n * × 2 = 10` — that it cannot itself straddle a genuine\n * `MIN_GUARANTEED_PATCH_PX`-wide bad patch and a safe neighbor closely\n * enough to dilute a real defect back above the floor (this file's own\n * test: a real 12px-wide dark patch at the same spot the noise test uses\n * still produces a finding).\n */\nconst AGGREGATION_HALF_PX = 1\n\ninterface WorstCaseSample {\n ratio: number\n background: string\n}\n\n/**\n * Dense sample-center positions from `lo` to `hi` inclusive, `SAMPLE_STRIDE_PX`\n * apart. Always includes both endpoints, even when the span isn't an exact\n * stride multiple (the last gap can be shorter than the stride, never\n * longer) — a run's own edges are never under-sampled relative to its\n * interior. `[lo]` when the span has collapsed to (or below) a point, same\n * degenerate-input guard the old fixed-fraction grid had.\n */\nfunction samplePositions(lo: number, hi: number): number[] {\n if (hi <= lo) return [lo]\n const positions: number[] = []\n for (let v = lo; v < hi; v += SAMPLE_STRIDE_PX) positions.push(v)\n positions.push(hi)\n return positions\n}\n\n/**\n * Mean RGB of the `(2*AGGREGATION_HALF_PX+1)²` pixel block centered at\n * `(cx, cy)` (rounded to the nearest pixel) — the noise-robustness\n * aggregation step, see `AGGREGATION_HALF_PX`'s own doc comment. Skips any\n * pixel that's off-canvas or below full alpha, same indeterminate rule the\n * original single-point design used; a window with at least one usable\n * pixel still contributes its partial average rather than being discarded\n * outright, maximizing real coverage near a run's own box edges. Returns\n * `null` only when the window contained zero usable pixels.\n */\nfunction averageWindow(image: RasterizedImage, cx: number, cy: number): { r: number; g: number; b: number } | null {\n const x0 = Math.round(cx)\n const y0 = Math.round(cy)\n let sumR = 0\n let sumG = 0\n let sumB = 0\n let count = 0\n for (let dy = -AGGREGATION_HALF_PX; dy <= AGGREGATION_HALF_PX; dy++) {\n const y = y0 + dy\n if (y < 0 || y >= image.height) continue\n for (let dx = -AGGREGATION_HALF_PX; dx <= AGGREGATION_HALF_PX; dx++) {\n const x = x0 + dx\n if (x < 0 || x >= image.width) continue\n const i = (y * image.width + x) * 4\n const alpha = image.data[i + 3]!\n if (alpha < 255) continue\n sumR += image.data[i]!\n sumG += image.data[i + 1]!\n sumB += image.data[i + 2]!\n count++\n }\n }\n if (count === 0) return null\n return { r: sumR / count, g: sumG / count, b: sumB / count }\n}\n\n/**\n * Grid-sample `run`'s estimated box against `image` at a dense,\n * deterministic stride (`SAMPLE_STRIDE_PX`) — each sample point itself a\n * small-window average (`averageWindow`/`AGGREGATION_HALF_PX`) rather than\n * one raw pixel — tracking the least-favorable (lowest) contrast ratio\n * found overall: spec §4.3 step 6's \"WCAG 最不利带\" (worst-case band).\n *\n * **Coverage guarantee:** any axis-aligned contiguous low-contrast patch at\n * least `MIN_GUARANTEED_PATCH_PX` (10px) wide *and* tall is always fully\n * covered by at least one sample's aggregation window, regardless of the\n * patch's position relative to the grid — no alignment/phase assumption,\n * unlike the fixed grid this replaces. Proof, one axis at a time: sample\n * centers sit at a fixed stride S=5px; a window centered at `c` is fully\n * inside a patch spanning `[a, a+10]` exactly when\n * `c ∈ [a+1, a+9]` (window half-width 1px each side) — an interval of\n * length 8 >= S, so by the covering property of a grid spaced S apart (any\n * interval of length >= S must contain a grid point, since consecutive\n * centers are only S apart), that interval always contains at least one\n * sample center. The same argument applies independently on the\n * perpendicular axis, so a 2D patch of that minimum size is always hit on\n * both axes at once. (The deep-acceptance review's demonstrated miss used a\n * 24px patch — comfortably inside this guarantee with margin to spare.)\n *\n * **Noise robustness:** see `AGGREGATION_HALF_PX`'s own doc comment — the\n * same small window that provides the guarantee above also means a single\n * noisy pixel can't flip a genuinely-safe patch into a false finding.\n *\n * Returns `null` when *no* sample window anywhere yielded any usable pixel\n * at all (every window skipped, or the run's box fell entirely outside the\n * rasterized canvas) — the caller treats that as \"nothing proven either\n * way\", not a finding.\n */\nfunction worstCaseSample(run: ImageBackedTextRun, image: RasterizedImage): WorstCaseSample | null {\n const top = run.baseline - run.fontSize * SAMPLE_ASCENT_RATIO\n const bottom = run.baseline + run.fontSize * SAMPLE_DESCENT_RATIO\n let worst: WorstCaseSample | null = null\n\n const ys = samplePositions(top, bottom)\n const xs = samplePositions(run.left, run.right)\n\n for (const y of ys) {\n for (const x of xs) {\n const avg = averageWindow(image, x, y)\n if (!avg) continue\n\n const bgHex = rgbToHex(avg.r, avg.g, avg.b)\n const effective = blendOver(run.fill, bgHex, run.alpha)\n const ratio = contrastRatio(effective, bgHex)\n if (!worst || ratio < worst.ratio) worst = { ratio, background: bgHex }\n }\n }\n return worst\n}\n\nexport interface PixelContrastIssue {\n text: string\n fill: string\n /** The small-window-averaged RGB (hex) at the worst-case sample center —\n * a rasterized-and-aggregated sample, never a resolved SVG paint (see\n * `averageWindow`). */\n background: string\n ratio: number\n required: number\n fontSize: number\n}\n\nfunction pixelContrastMessage(issue: PixelContrastIssue): string {\n return (\n `text \"${issue.text}\" has a pixel-sampled contrast ratio of ${issue.ratio.toFixed(2)}:1 against its image background ` +\n `(sampled ${issue.background}, target ${issue.required}:1) — this text sits directly on an image with no resolvable ` +\n `solid backing color; reposition it, add an opaque-enough scrim behind it, or recolor the text`\n )\n}\n\ntype Rasterizer = (svgMarkup: string, width: number, height: number) => Promise<RasterizedImage>\n\n/**\n * `getPlatform().rasterizeSvg` (Sharp once `installNodePlatform()` ran) or\n * the browser default — the exact same `?? fallback` shape `deck-audit.ts`'s\n * `parseSvg` already uses for `domParser`. Never throws itself: an\n * environment with neither capability surfaces through\n * `rasterizeSvgInBrowser`'s own curated \"rasterizeSvg unavailable\" error the\n * first time the returned function is actually called (its `Image`/canvas\n * capability guards — see `browser.ts`), which is the explicit-failure\n * contract spec §11.7's \"契约层\" asks for either way.\n */\nfunction resolveRasterizer(): Rasterizer {\n return getPlatform().rasterizeSvg ?? rasterizeSvgInBrowser\n}\n\nasync function pixelFindingsForPage(\n markup: string,\n page: number,\n slideId: string | undefined,\n rasterize: Rasterizer,\n): Promise<AuditFinding[]> {\n const runs = __collectImageBackedTextRuns(markup)\n if (runs.length === 0) return []\n\n const stripped = stripTextNodes(markup)\n const image = await rasterize(stripped, PIXEL_CANVAS_W, PIXEL_CANVAS_H)\n\n const findings: AuditFinding[] = []\n for (const run of runs) {\n const sampled = worstCaseSample(run, image)\n if (!sampled) continue\n if (sampled.ratio < PIXEL_HARD_FINDING_MAX_RATIO) {\n const issue: PixelContrastIssue = {\n text: run.text,\n fill: run.fill,\n background: sampled.background,\n ratio: sampled.ratio,\n required: run.required,\n fontSize: run.fontSize,\n }\n findings.push({\n page,\n ...(slideId !== undefined ? { slideId } : {}),\n code: \"low-contrast\",\n message: pixelContrastMessage(issue),\n // `source: \"pixels\"` distinguishes this from an SVG-color-resolved\n // low-contrast finding's own `detail` shape (`ContrastIssue`,\n // `deck-audit.ts`) — same `code`, since both are the same category\n // of defect (text fails contrast against its real background), just\n // measured through a different, more reliable source for that\n // background's color.\n detail: { ...issue, source: \"pixels\" },\n })\n }\n }\n return findings\n}\n\n/**\n * Test-only re-export (`__`-prefixed, same \"SDK-internal, not part of any\n * public barrel\" convention `deck-audit.ts`'s own `__collectBgRegions`/\n * `__pathBoundingBox` establish): lets `pixel-audit.test.ts` exercise the\n * sampling-grid + `PIXEL_HARD_FINDING_MAX_RATIO` threshold logic directly,\n * with a hand-crafted `rasterize` function returning exact, controlled\n * pixel data — real component geometry (`image-pages.tsx`'s `DarkScrim`, in\n * particular) turned out unable to organically produce a sub-1.5 ratio for\n * *any* photo brightness at the org-line's single-scrim-layer position\n * (confirmed empirically while building this task's own test suite: even a\n * pure-white source image only reaches ~1.83), which is the calibration\n * working as designed (spec §4.3's own \"control false positives\" gate) but\n * makes the threshold-crossing branch unreachable through real IR/archetype\n * fixtures alone.\n */\nexport const __pixelFindingsForPage = pixelFindingsForPage\n\n/**\n * The pixel-audit pass over an already-valid deck — `auditDeck(ir, {pixels:\n * true})`'s own async branch (`deck-audit.ts`). Independently walks\n * `ir.slides` (skipping placeholders the same way `runDeterministicAudit`\n * does) rather than reusing that function's own loop, so this module has no\n * static dependency back on it beyond the few named imports at the top of\n * this file — `renderSlideSvg` is pure and synchronous, so re-rendering\n * each non-placeholder slide a second time costs a little CPU, never\n * correctness (spec's own \"no second renderer\" non-goal is about a\n * *different* rendering path, not calling the one true renderer twice).\n *\n * Sequential, not `Promise.all` — deliberately bounds rasterization\n * concurrency to 1 (each Sharp call is real CPU-bound work) and makes the\n * page-order-stable output an obvious property of the code rather than an\n * incidental one `Promise.all`'s array-order guarantee happens to provide.\n *\n * A rasterization failure on any one page (missing platform capability, a\n * remote asset reference, a tainted canvas) propagates out of this function\n * entirely, aborting the whole pixel pass rather than silently skipping\n * just that page — spec §11.7's \"契约层\": a requested-but-failed pixel audit\n * is an explicit failure, never a partial \"clean\".\n */\nexport async function runPixelContrastAudit(ir: PptxIR): Promise<AuditFinding[]> {\n const rasterize = resolveRasterizer()\n const findings: AuditFinding[] = []\n for (let i = 0; i < ir.slides.length; i++) {\n const slide = ir.slides[i]!\n if (slide.placeholder) continue\n const page = i + 1\n const slideId = slide.id\n const markup = renderSlideSvg(ir, i)\n findings.push(...(await pixelFindingsForPage(markup, page, slideId, rasterize)))\n }\n return findings\n}\n"],"mappings":"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","names":[]}