@flighthq/swf 0.3.0 → 0.3.1-next.320.fe56fc2

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 (43) hide show
  1. package/dist/contract.d.ts +1 -0
  2. package/dist/contract.d.ts.map +1 -1
  3. package/dist/contract.js +1 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/swfDocument.d.ts +5 -5
  10. package/dist/swfDocument.d.ts.map +1 -1
  11. package/dist/swfDocument.js +359 -102
  12. package/dist/swfDocument.js.map +1 -1
  13. package/dist/swfFilter.d.ts +2 -2
  14. package/dist/swfFilter.d.ts.map +1 -1
  15. package/dist/swfFilter.js +24 -2
  16. package/dist/swfFilter.js.map +1 -1
  17. package/dist/swfFrameAction.d.ts +2 -2
  18. package/dist/swfFrameAction.d.ts.map +1 -1
  19. package/dist/swfFrameAction.js +13 -6
  20. package/dist/swfFrameAction.js.map +1 -1
  21. package/dist/swfFrameActionTestHelper.d.ts +2 -0
  22. package/dist/swfFrameActionTestHelper.d.ts.map +1 -0
  23. package/dist/swfFrameActionTestHelper.js +99 -0
  24. package/dist/swfFrameActionTestHelper.js.map +1 -0
  25. package/dist/swfImageDecoder.d.ts +4 -0
  26. package/dist/swfImageDecoder.d.ts.map +1 -0
  27. package/dist/swfImageDecoder.js +21 -0
  28. package/dist/swfImageDecoder.js.map +1 -0
  29. package/dist/swfMorphShape.d.ts +2 -1
  30. package/dist/swfMorphShape.d.ts.map +1 -1
  31. package/dist/swfMorphShape.js +18 -7
  32. package/dist/swfMorphShape.js.map +1 -1
  33. package/dist/swfText.d.ts +2 -2
  34. package/dist/swfText.d.ts.map +1 -1
  35. package/dist/swfText.js +15 -2
  36. package/dist/swfText.js.map +1 -1
  37. package/package.json +24 -22
  38. package/src/swfDocument.test.ts +1527 -163
  39. package/src/swfFilter.test.ts +53 -0
  40. package/src/swfFrameAction.test.ts +26 -97
  41. package/src/swfImageDecoder.test.ts +53 -0
  42. package/src/swfImageResources.test.ts +14 -17
  43. package/src/swfMorphShape.test.ts +18 -0
@@ -4,17 +4,18 @@ import { createClipRegionFromContours, createClipRegionFromPath } from '@flighth
4
4
  import { getDecompressor } from '@flighthq/compression/contract';
5
5
  import { createMatrix, inverseMatrix, matrixTransformPointXY, multiplyMatrix } from '@flighthq/geometry/contract';
6
6
  import { createEmbeddedImageResourceReference } from '@flighthq/image/contract';
7
+ import { reportImportDiagnostic } from '@flighthq/importdiagnostics/contract';
7
8
  import { addMovieClipFrameScript, createMovieClip, setMovieClipSource } from '@flighthq/movieclip/contract';
8
9
  import { addNodeChild, addNodeOrderListEntry, applyNodeOrderList, clearNodeOrderList, createNodeOrderList, getNodeRuntime, invalidateNodeAppearance, invalidateNodeLocalBounds, removeNodeChild, setNodeColorAdjustments, setNodeLocalMatrix, } from '@flighthq/node/contract';
9
10
  import { createScene2DDocument, createScene2DSlotReference, registerScene2DDocumentImporter, } from '@flighthq/scene2d-resources/contract';
10
11
  import { createDisplayObject, createSprite, setNode2DClip } from '@flighthq/scene2d/contract';
11
12
  import { copyShapeCommands, createScale9Shape, createShape, getShapeFillRegions, setMorphShapeProgress, } from '@flighthq/shape/contract';
12
- import { createSampler, createTexture, setTextureSource } from '@flighthq/texture/contract';
13
- import { AdvancedBlendMode, BlendMode, Compression, MorphShapeKind, TimelineAudioCueKind, TimelineStreamAudioCueKind, } from '@flighthq/types/contract';
14
- import { createSwfLosslessBitmap } from './swfBitmap';
13
+ import { createSampler, createTexture } from '@flighthq/texture/contract';
14
+ import { AdvancedBlendMode, BlendMode, Compression, ImportDiagnosticSeverity, MorphShapeKind, TimelineAudioCueKind, TimelineStreamAudioCueKind, } from '@flighthq/types/contract';
15
15
  import { readSwfEditTextFactory } from './swfEditText';
16
16
  import { readSwfFilterList } from './swfFilter';
17
17
  import { readSwfAbcFrameScripts, readSwfFrameActions } from './swfFrameAction';
18
+ import { SWF_LOSSLESS_ALPHA_MIME_TYPE, SWF_LOSSLESS_MIME_TYPE } from './swfImageDecoder';
18
19
  import { createSwfMorphShape } from './swfMorphShape';
19
20
  import { SwfReader } from './swfReader';
20
21
  import { createSwfShape } from './swfShape';
@@ -22,23 +23,23 @@ import { createSwfTextShape, readSwfFontGlyphOutlineSource } from './swfText';
22
23
  // Recovers every embedded DefineFont/2/3 as the generic, glyph-index-keyed outline seam. The map key
23
24
  // is the SWF character id used by DefineText and DefineEditText. This is a separate parse entry from
24
25
  // Scene2D construction so callers that only need embedded fonts do not have to retain a document.
25
- export function createGlyphOutlineSourcesFromSwf(source) {
26
- const file = readSwfFile(source);
26
+ export function createGlyphOutlineSourcesFromSwf(source, diagnostics) {
27
+ const file = readSwfFile(source, diagnostics);
27
28
  return file === null ? null : new Map(file.parsed.fontOutlineSources);
28
29
  }
29
30
  // The document alone, for a caller that wants the graph and nothing else — the importer registry among
30
31
  // them. A file whose placements carry an advanced blend or a filter list still imports fully here; what
31
32
  // it loses is the report of them, which is what createScene2DImportFromSwf returns.
32
- export function createScene2DFromSwf(source) {
33
- return createScene2DImportFromSwf(source)?.document ?? null;
33
+ export function createScene2DFromSwf(source, diagnostics) {
34
+ return createScene2DImportFromSwf(source, diagnostics)?.document ?? null;
34
35
  }
35
36
  // The full import: the document, plus the placement appearance no node can carry. SWF puts a blend mode
36
37
  // and a filter list on the same record as the matrix, and Flight expresses neither on a node — an
37
38
  // advanced blend needs a BlendEffect and an effect is a descriptor a caller runs explicitly, since
38
39
  // `displayObject.filters` is an anti-goal. Both therefore travel beside the document instead of being
39
40
  // dropped at the seam or silently flattened onto a node.
40
- export function createScene2DImportFromSwf(source) {
41
- const file = readSwfFile(source);
41
+ export function createScene2DImportFromSwf(source, diagnostics) {
42
+ const file = readSwfFile(source, diagnostics);
42
43
  if (file === null)
43
44
  return null;
44
45
  const { frameRate, parsed, stageBounds } = file;
@@ -46,14 +47,16 @@ export function createScene2DImportFromSwf(source) {
46
47
  const instantiation = {
47
48
  activeSymbols: new Set(),
48
49
  appearances: [],
50
+ diagnostics,
49
51
  frameRate: frameRate > 0 ? frameRate : null,
50
52
  resolvingBounds: new Set(),
51
53
  resolvedBounds: new Map(),
52
54
  };
53
55
  const root = createSwfTimelineNode(parsed.timeline, stageBounds, parsed, slots, instantiation, 0);
54
- if (root === null)
56
+ if (root === null) {
57
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.timeline-instantiation-failed', 'createScene2DImportFromSwf');
55
58
  return null;
56
- fillSwfLosslessBitmapTextures(parsed);
59
+ }
57
60
  return {
58
61
  appearances: instantiation.appearances,
59
62
  document: createScene2DDocument(root, slots, 'swf', parsed.backgroundColor, createSwfImageResources(parsed), createSwfAudioResources(parsed)),
@@ -70,8 +73,8 @@ export function createScene2DImportFromSwf(source) {
70
73
  // Anything less would hand back artwork whose bitmaps could never be paired with their pixels, since
71
74
  // each call parses afresh and its Textures are its own. `backgroundColor` stays null: the stage colour
72
75
  // belongs to the stage, and a symbol instantiated into someone else's scene is not it.
73
- export function createScene2DSymbolFromSwf(source, linkageName) {
74
- const file = readSwfFile(source);
76
+ export function createScene2DSymbolFromSwf(source, linkageName, diagnostics) {
77
+ const file = readSwfFile(source, diagnostics);
75
78
  if (file === null)
76
79
  return null;
77
80
  const { frameRate, parsed } = file;
@@ -80,12 +83,19 @@ export function createScene2DSymbolFromSwf(source, linkageName) {
80
83
  if (name === linkageName)
81
84
  characterId = id;
82
85
  }
83
- if (characterId < 0)
86
+ // A linkage name nothing exports is the caller naming a symbol this file does not have — worth telling
87
+ // them apart from a file that failed to parse, since both come back null.
88
+ if (characterId < 0) {
89
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.unknown-linkage-name', 'createScene2DSymbolFromSwf', {
90
+ linkageName,
91
+ });
84
92
  return null;
93
+ }
85
94
  const slots = [];
86
95
  const instantiation = {
87
96
  activeSymbols: new Set(),
88
97
  appearances: [],
98
+ diagnostics,
89
99
  frameRate: frameRate > 0 ? frameRate : null,
90
100
  resolvingBounds: new Set(),
91
101
  resolvedBounds: new Map(),
@@ -93,7 +103,6 @@ export function createScene2DSymbolFromSwf(source, linkageName) {
93
103
  const root = createSwfSymbolNode(parsed, characterId, slots, instantiation);
94
104
  if (root === null)
95
105
  return null;
96
- fillSwfLosslessBitmapTextures(parsed);
97
106
  return createScene2DDocument(root, slots, 'swf', null, createSwfImageResources(parsed), createSwfAudioResources(parsed));
98
107
  }
99
108
  // Every linkage name the file exported, whether or not the symbol was ever placed. Pair with
@@ -105,58 +114,51 @@ export function readSwfExportedSymbolNames(source) {
105
114
  export function registerSwfScene2DDocumentImporter(registry) {
106
115
  registerScene2DDocumentImporter(registry, 'swf', matchesSwfDocument, (source) => createScene2DFromSwf(source));
107
116
  }
108
- function readSwfFile(source) {
109
- const uncompressed = uncompressSwfSource(source);
117
+ function readSwfFile(source, diagnostics) {
118
+ const uncompressed = uncompressSwfSource(source, diagnostics);
110
119
  if (uncompressed === null)
111
120
  return null;
121
+ // Every rejection below loses the WHOLE document, and each has a distinct cause. Without a report per
122
+ // cause the caller receives one null from `createScene2DFromSwf` and cannot tell an unregistered
123
+ // decompressor — which IS reported, upstream — from a truncated header, which was not.
112
124
  const header = new SwfReader(uncompressed, 0, uncompressed.length);
113
125
  const signature = header.readUint8();
114
126
  if (signature !== FWS_SIGNATURE || header.readUint8() !== W_SIGNATURE || header.readUint8() !== S_SIGNATURE) {
127
+ // Distinct from the container's `swf.invalid-signature`: that one reads the ORIGINAL bytes, this one
128
+ // reads the decompressed body, whose header the decompressor rewrote. NOTHING CAN REACH THIS TODAY —
129
+ // an FWS container is returned unchanged and every compressed path writes `FWS_SIGNATURE` into byte 0,
130
+ // while bytes 1 and 2 were validated before either. The report stays because unreachable-by-
131
+ // construction is a property of today's two container paths, not of the format: a third path, or a
132
+ // decompressor that stops rewriting the header, makes it reachable, and silence would return with it.
133
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.uncompressed-signature-invalid', 'readSwfFile');
115
134
  return null;
116
135
  }
117
136
  const version = header.readUint8();
118
137
  const fileLength = header.readUint32();
119
- if (!header.valid || version === 0 || fileLength < MIN_SWF_LENGTH || fileLength > uncompressed.length)
138
+ if (!header.valid || version === 0 || fileLength < MIN_SWF_LENGTH || fileLength > uncompressed.length) {
139
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.header-fields-invalid', 'readSwfFile', {
140
+ declaredLength: fileLength,
141
+ available: uncompressed.length,
142
+ version,
143
+ });
120
144
  return null;
145
+ }
121
146
  const body = new SwfReader(uncompressed, SWF_PREFIX_LENGTH, fileLength);
122
147
  const stageBounds = readSwfRectangle(body);
123
- if (stageBounds === null)
148
+ if (stageBounds === null) {
149
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.stage-bounds-unreadable', 'readSwfFile');
124
150
  return null;
151
+ }
125
152
  // Header FrameRate is 8.8 fixed and governs every timeline in the file; the authored FrameCount that
126
153
  // follows it is advisory, so the real root frame count comes from the ShowFrame tags themselves.
127
154
  const frameRate = body.readUint16() / FIXED_8_8_ONE;
128
155
  body.readUint16();
129
- if (!body.valid)
156
+ if (!body.valid) {
157
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.header-truncated', 'readSwfFile');
130
158
  return null;
131
- const parsed = readSwfTags(body);
132
- return parsed === null ? null : { frameRate, parsed, stageBounds };
133
- }
134
- // Fills in the pixels this package can unpack on its own. A lossless definition is not an image file — it
135
- // is a raw raster plus zlib — so no generic decoder reaches it, but both halves needed to read it are
136
- // already here: the shared decompressor the caller registered, and the layout knowledge in swfBitmap. Its
137
- // pixels therefore resolve at import, and it earns no image resource because there is nothing left to
138
- // load. The encoded formats leave as references and decode through @flighthq/image later.
139
- //
140
- // Only characters something actually samples are unpacked, so a library full of unused bitmaps costs
141
- // nothing. That is the whole reason this walks the texture map rather than the payload map.
142
- function fillSwfLosslessBitmapTextures(parsed) {
143
- for (const [characterId, variants] of parsed.imageTextures) {
144
- if (!isSwfLosslessImage(parsed.images.get(characterId)))
145
- continue;
146
- const image = parsed.images.get(characterId);
147
- if (image === undefined)
148
- continue;
149
- const bitmap = createSwfLosslessBitmap(image.bytes, image.mimeType === SWF_LOSSLESS_ALPHA_MIME_TYPE);
150
- if (bitmap === null)
151
- continue;
152
- for (const texture of variants.values())
153
- setTextureSource(texture, bitmap);
154
159
  }
155
- }
156
- function isSwfLosslessImage(image) {
157
- if (image === undefined)
158
- return false;
159
- return image.mimeType === SWF_LOSSLESS_MIME_TYPE || image.mimeType === SWF_LOSSLESS_ALPHA_MIME_TYPE;
160
+ const parsed = readSwfTags(body, diagnostics);
161
+ return parsed === null ? null : { frameRate, parsed, stageBounds };
160
162
  }
161
163
  // Presents any container form as the uncompressed bytes the rest of the importer reads. `FWS` is already
162
164
  // that and is returned as-is, with no copy. `CWS` and `ZWS` compress everything after the 8-byte header,
@@ -164,32 +166,60 @@ function isSwfLosslessImage(image) {
164
166
  // to `FWS` — the declared length already counts uncompressed bytes, so it carries over untouched.
165
167
  // Compression the caller has not registered a decompressor for is reported as the document's null
166
168
  // sentinel, exactly like a malformed file: the bytes are unreadable either way.
167
- function uncompressSwfSource(source) {
168
- if (source.length < SWF_PREFIX_LENGTH || source[1] !== W_SIGNATURE || source[2] !== S_SIGNATURE)
169
+ function uncompressSwfSource(source, diagnostics) {
170
+ if (source.length < SWF_PREFIX_LENGTH || source[1] !== W_SIGNATURE || source[2] !== S_SIGNATURE) {
171
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.invalid-signature', 'uncompressSwfSource', {
172
+ length: source.length,
173
+ });
169
174
  return null;
175
+ }
170
176
  const signature = source[0];
171
177
  if (signature === FWS_SIGNATURE)
172
178
  return source;
173
179
  const compression = signature === CWS_SIGNATURE ? Compression.Deflate : signature === ZWS_SIGNATURE ? Compression.Lzma : null;
174
- if (compression === null)
180
+ if (compression === null) {
181
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.unknown-container', 'uncompressSwfSource', {
182
+ signature,
183
+ });
175
184
  return null;
185
+ }
176
186
  const decompress = getDecompressor(compression);
177
- if (decompress === null)
187
+ // Distinct from a malformed body on purpose. Both return the same null sentinel, but a caller that
188
+ // never registered a decompressor has a file it could read after one registration, while a corrupt
189
+ // stream is unreadable however the caller is configured — and only the crumb can tell them apart.
190
+ if (decompress === null) {
191
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.no-decompressor-registered', 'uncompressSwfSource', { compression });
178
192
  return null;
193
+ }
179
194
  const header = new SwfReader(source, 0, SWF_PREFIX_LENGTH);
180
195
  header.readUint32();
181
196
  const fileLength = header.readUint32();
182
- if (fileLength < MIN_SWF_LENGTH)
197
+ if (fileLength < MIN_SWF_LENGTH) {
198
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.declared-length-too-small', 'uncompressSwfSource', {
199
+ fileLength,
200
+ });
183
201
  return null;
202
+ }
184
203
  // LZMA puts a compressed length and the 5 property bytes between the header and its stream; zlib starts
185
204
  // its stream immediately. Either way the decompressor receives the stream itself.
186
205
  const bodyLength = fileLength - SWF_PREFIX_LENGTH;
187
206
  const streamStart = compression === Compression.Lzma ? SWF_LZMA_PREFIX_LENGTH : SWF_PREFIX_LENGTH;
188
- if (streamStart > source.length)
207
+ if (streamStart > source.length) {
208
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.truncated-container', 'uncompressSwfSource', {
209
+ length: source.length,
210
+ streamStart,
211
+ });
189
212
  return null;
213
+ }
190
214
  const body = decompress(source.subarray(streamStart), bodyLength);
191
- if (body === null || body.length < bodyLength)
215
+ if (body === null || body.length < bodyLength) {
216
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.decompression-failed', 'uncompressSwfSource', {
217
+ compression,
218
+ expected: bodyLength,
219
+ received: body === null ? -1 : body.length,
220
+ });
192
221
  return null;
222
+ }
193
223
  const uncompressed = new Uint8Array(SWF_PREFIX_LENGTH + bodyLength);
194
224
  uncompressed.set(source.subarray(0, SWF_PREFIX_LENGTH));
195
225
  uncompressed[0] = FWS_SIGNATURE;
@@ -229,7 +259,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
229
259
  const clips = new Map();
230
260
  for (const frame of timeline.frames) {
231
261
  const ordered = [...frame.values()].sort(compareSwfPlacementDepth);
232
- frames.push(buildSwfFrameEntries(ordered, parsed, clips));
262
+ frames.push(buildSwfFrameEntries(ordered, parsed, clips, state.diagnostics));
233
263
  for (const placement of ordered) {
234
264
  const key = createSwfInstanceKey(placement);
235
265
  if (nodes.has(key))
@@ -268,7 +298,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
268
298
  const target = scale9 !== null
269
299
  ? scale9
270
300
  : editText !== undefined
271
- ? createSwfEditTextTarget(editText, parsed, targetBounds)
301
+ ? createSwfEditTextTarget(editText, parsed, targetBounds, state.diagnostics)
272
302
  : image !== undefined
273
303
  ? createSwfTexturedSprite(acquireSwfImageTexture(parsed, placement.characterId, false, true), targetBounds)
274
304
  : video !== undefined
@@ -291,7 +321,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
291
321
  }
292
322
  }
293
323
  }
294
- collectSwfNodeAppearances(frames, nodes, state.appearances);
324
+ collectSwfNodeAppearances(frames, nodes, state.appearances, state.diagnostics);
295
325
  setMovieClipSource(clip, createSwfTimelineSource(frames, nodes, timeline.labels, timeline.cues, state.frameRate));
296
326
  // Frame scripts attach after the source, so the clip already knows how many frames it has when a
297
327
  // recognized command addresses one.
@@ -307,15 +337,24 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
307
337
  //
308
338
  // One entry per (instance, frame) that carries either, so a filter that changes across frames reads as
309
339
  // the per-frame data it is. Frames are 1-based, matching gotoAndStopMovieClip.
310
- function collectSwfNodeAppearances(frames, nodes, out) {
340
+ function collectSwfNodeAppearances(frames, nodes, out, diagnostics) {
311
341
  for (let frame = 0; frame < frames.length; frame++) {
312
342
  for (const entry of frames[frame]) {
313
343
  const { advancedBlendMode, effects } = entry.placement;
314
344
  if (advancedBlendMode === null && effects.length === 0)
315
345
  continue;
316
346
  const node = nodes.get(createSwfInstanceKey(entry.placement));
317
- if (node === undefined)
347
+ if (node === undefined) {
348
+ // The appearance report is the only carrier for these two channels, so a placement whose node was
349
+ // never allocated loses them outright. Reaching here means the placement declared one or both,
350
+ // which is why the earlier `continue` is not a loss and this one is. A masking placement cannot
351
+ // reach here despite also earning no node: masks are excluded from frame entries upstream.
352
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.appearance-without-node', 'collectSwfNodeAppearances', {
353
+ capability: advancedBlendMode !== null ? 'swf.placement.blend-mode' : 'swf.placement.filter-list',
354
+ frame: frame + 1,
355
+ });
318
356
  continue;
357
+ }
319
358
  out.push({ advancedBlendMode, effects: [...effects], frame: frame + 1, node });
320
359
  }
321
360
  }
@@ -424,28 +463,51 @@ function createSwfTimelineSource(frames, nodes, labels, cues, frameRate) {
424
463
  // placement with a clip depth covers every depth above its own through that clip depth — while Flight
425
464
  // clips a node and its subtree. Applying one region to each covered sibling is equivalent to grouping
426
465
  // them under a clipped container, and it leaves the attach/detach/reorder path untouched.
427
- function buildSwfFrameEntries(ordered, parsed, clips) {
466
+ function buildSwfFrameEntries(ordered, parsed, clips, diagnostics) {
428
467
  const entries = [];
429
468
  for (const placement of ordered) {
430
469
  if (placement.clipDepth > 0)
431
470
  continue;
432
- const mask = resolveSwfPlacementMask(ordered, placement);
433
- entries.push({ clip: mask === null ? null : resolveSwfMaskClip(mask, placement, parsed, clips), placement });
471
+ const mask = resolveSwfPlacementMask(ordered, placement, diagnostics);
472
+ const clip = mask === null ? null : resolveSwfMaskClip(mask, placement, parsed, clips);
473
+ // A mask that resolves to no region leaves its covered instance UNCLIPPED, which is a visible
474
+ // difference and not a no-op: the mask character had no decoded geometry, so imposing nothing was
475
+ // the honest choice over imposing a wrong clip. Recover rather than Drop — the instance still draws.
476
+ if (mask !== null && clip === null) {
477
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'swf.mask-without-geometry', 'buildSwfFrameEntries', {
478
+ capability: 'swf.placement.clip-depth',
479
+ depth: placement.depth,
480
+ maskCharacterId: mask.characterId,
481
+ });
482
+ }
483
+ entries.push({ clip, placement });
434
484
  }
435
485
  return entries;
436
486
  }
437
487
  // The innermost mask covering a depth. Flight carries one clip per node, so where masks nest, the
438
488
  // deepest one wins rather than intersecting them.
439
- function resolveSwfPlacementMask(ordered, placement) {
489
+ function resolveSwfPlacementMask(ordered, placement, diagnostics) {
440
490
  let mask = null;
491
+ let covering = 0;
441
492
  for (const candidate of ordered) {
442
493
  if (candidate.clipDepth <= 0 || candidate.depth >= placement.depth)
443
494
  continue;
444
495
  if (placement.depth > candidate.clipDepth)
445
496
  continue;
497
+ covering++;
446
498
  if (mask === null || candidate.depth > mask.depth)
447
499
  mask = candidate;
448
500
  }
501
+ // Two masks over one instance means the outer one is not applied at all, so the instance shows more
502
+ // than the file said it should. Skip rather than Recover: a node carries one clip, so this is a
503
+ // vocabulary gap in the clip subject rather than geometry this decoder failed to read.
504
+ if (covering > 1) {
505
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'swf.nested-mask-collapsed', 'resolveSwfPlacementMask', {
506
+ capability: 'swf.placement.clip-depth',
507
+ covering,
508
+ depth: placement.depth,
509
+ });
510
+ }
449
511
  return mask;
450
512
  }
451
513
  function resolveSwfMaskClip(mask, placement, parsed, clips) {
@@ -584,11 +646,14 @@ function createSwfImageResources(parsed) {
584
646
  const resources = [];
585
647
  for (const [characterId, variants] of parsed.imageTextures) {
586
648
  const image = parsed.images.get(characterId);
587
- // A lossless payload already resolved at import through swfBitmap, so a reference for it would report
588
- // work that does not exist and would fail if anything tried to decode it as an image file.
589
- if (image === undefined || isSwfLosslessImage(image))
649
+ if (image === undefined)
590
650
  continue;
591
- const reference = createEmbeddedImageResourceReference(image.bytes, image.mimeType);
651
+ const alphaType = image.mimeType === SWF_LOSSLESS_ALPHA_MIME_TYPE
652
+ ? 'premultiplied'
653
+ : image.mimeType === SWF_LOSSLESS_MIME_TYPE
654
+ ? 'opaque'
655
+ : 'straight';
656
+ const reference = createEmbeddedImageResourceReference(image.bytes, image.mimeType, alphaType);
592
657
  reference.textures = [...variants.values()];
593
658
  resources.push(reference);
594
659
  }
@@ -624,8 +689,21 @@ function createSwfMovieClip(bounds) {
624
689
  // A field becomes its own node per placement, because its text is per-instance state rather than shared
625
690
  // artwork. The authored RECT still sizes it, so a field reports the box the tool drew even before any
626
691
  // layout has run.
627
- function createSwfEditTextTarget(create, parsed, bounds) {
628
- const node = create((fontId) => parsed.fontNames.get(fontId) ?? '');
692
+ function createSwfEditTextTarget(create, parsed, bounds, diagnostics) {
693
+ // The field survives with its size, box and colour and simply has no font family, which is the
694
+ // diminished case: it exists, it is smaller than authored, and nothing else says so. Reported once per
695
+ // unresolved id rather than per call, since the resolver runs for every run of text.
696
+ const unresolved = new Set();
697
+ const node = create((fontId) => {
698
+ const name = parsed.fontNames.get(fontId);
699
+ if (name !== undefined)
700
+ return name;
701
+ if (!unresolved.has(fontId)) {
702
+ unresolved.add(fontId);
703
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.edit-text-font-name-unresolved', 'createSwfEditTextTarget', { capability: 'swf.text.define-edit-text', fontId });
704
+ }
705
+ return '';
706
+ });
629
707
  if (bounds !== null) {
630
708
  node.data.authoredBounds = { ...bounds };
631
709
  getNodeRuntime(node).computeLocalBoundsRectangle = computeSwfLocalBoundsRectangle;
@@ -726,7 +804,7 @@ function createSwfSymbolNode(parsed, characterId, slots, state) {
726
804
  const bounds = resolveSwfCharacterBounds(parsed, characterId, state, 0);
727
805
  const editText = parsed.editTexts.get(characterId);
728
806
  if (editText !== undefined)
729
- return createSwfEditTextTarget(editText, parsed, bounds);
807
+ return createSwfEditTextTarget(editText, parsed, bounds, state.diagnostics);
730
808
  if (parsed.images.has(characterId)) {
731
809
  return createSwfTexturedSprite(acquireSwfImageTexture(parsed, characterId, false, true), bounds);
732
810
  }
@@ -759,16 +837,29 @@ function resolveSwfCharacterBounds(parsed, characterId, state, depth) {
759
837
  // A symbol's authored extent covers everything it can show, so this unions every frame's placements
760
838
  // rather than only the first frame's: the node's local bounds do not change as its playhead moves.
761
839
  let bounds = null;
840
+ let missingChildren = 0;
762
841
  for (const frame of sprite.frames) {
763
842
  for (const placement of frame.values()) {
764
843
  const childBounds = resolveSwfCharacterBounds(parsed, placement.characterId, state, depth + 1);
765
- if (childBounds === null)
844
+ if (childBounds === null) {
845
+ // Two different things reach here and only one is a loss. A sprite that resolved to an empty
846
+ // union has no extent to contribute and is correct; a character with neither authored bounds nor
847
+ // a sprite body was never imported at all, and the union it should have widened is short.
848
+ if (!parsed.characterBounds.has(placement.characterId) && !parsed.sprites.has(placement.characterId)) {
849
+ missingChildren++;
850
+ }
766
851
  continue;
852
+ }
767
853
  const transformed = transformSwfRectangle(childBounds, placement.matrix);
768
854
  bounds = bounds === null ? transformed : mergeSwfRectangles(bounds, transformed);
769
855
  }
770
856
  }
771
857
  state.resolvingBounds.delete(characterId);
858
+ if (missingChildren > 0) {
859
+ // A count rather than a flag: the box survives and is simply smaller than the sprite's contents, and
860
+ // a count is the only thing that separates a full union from a short one.
861
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.sprite-bounds-short', 'resolveSwfCharacterBounds', { capability: 'swf.timeline.define-sprite', characterId, missingChildren });
862
+ }
772
863
  state.resolvedBounds.set(characterId, bounds);
773
864
  return bounds;
774
865
  }
@@ -787,7 +878,7 @@ function transformSwfRectangle(bounds, matrix) {
787
878
  const maxY = Math.max(y0, y1, y2, y3);
788
879
  return { height: maxY - y, width: maxX - x, x, y };
789
880
  }
790
- function readPlaceObject(body, placements, hasExtendedFlags) {
881
+ function readPlaceObject(body, placements, hasExtendedFlags, diagnostics) {
791
882
  const flags = body.readUint8();
792
883
  const extendedFlags = hasExtendedFlags ? body.readUint8() : 0;
793
884
  const depth = body.readUint16();
@@ -811,8 +902,15 @@ function readPlaceObject(body, placements, hasExtendedFlags) {
811
902
  const hasFilterList = (extendedFlags & 0x01) !== 0;
812
903
  const readEffects = [];
813
904
  const readFilterAdjustments = [];
814
- const filterListComplete = !hasFilterList || readSwfFilterList(body, readEffects, readFilterAdjustments);
815
- const hasBlendMode = (extendedFlags & 0x02) !== 0 && filterListComplete;
905
+ const filterListComplete = !hasFilterList || readSwfFilterList(body, readEffects, readFilterAdjustments, diagnostics);
906
+ const declaresBlendMode = (extendedFlags & 0x02) !== 0;
907
+ const hasBlendMode = declaresBlendMode && filterListComplete;
908
+ if (declaresBlendMode && !filterListComplete) {
909
+ // The record declared a blend mode and the byte carrying it is unreachable behind a list that did
910
+ // not finish. The placement keeps the filters read so far, so nothing about the result says a
911
+ // declared channel went unread.
912
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.blend-mode-behind-unread-filters', 'readPlaceObject', { capability: 'swf.placement.blend-mode' });
913
+ }
816
914
  const blendModeValue = hasBlendMode ? body.readUint8() : 0;
817
915
  if (!body.valid || (isMove && existing === undefined) || (characterId === 0 && directLinkage === null))
818
916
  return;
@@ -1006,10 +1104,11 @@ function readSwfRectangle(reader) {
1006
1104
  y: yMin / TWIPS_PER_PIXEL,
1007
1105
  };
1008
1106
  }
1009
- function readSwfTags(reader) {
1107
+ function readSwfTags(reader, diagnostics) {
1010
1108
  const state = {
1011
1109
  abcBlobs: [],
1012
1110
  backgroundColor: null,
1111
+ diagnostics,
1013
1112
  pendingInitActions: [],
1014
1113
  editTexts: new Map(),
1015
1114
  fontNames: new Map(),
@@ -1098,9 +1197,11 @@ function appendSwfAbcFrameScripts(state, timeline) {
1098
1197
  for (const [characterId, className] of state.linkages)
1099
1198
  charactersByClass.set(className, characterId);
1100
1199
  for (const blob of state.abcBlobs) {
1101
- const byClass = readSwfAbcFrameScripts(blob);
1102
- if (byClass === null)
1200
+ const byClass = readSwfAbcFrameScripts(blob.bytes, state.diagnostics);
1201
+ if (byClass === null) {
1202
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.abc-frame-scripts-unreadable', 'appendSwfAbcFrameScripts', { capability: blob.named ? 'swf.script.do-abc' : 'swf.script.do-abc-anonymous' });
1103
1203
  continue;
1204
+ }
1104
1205
  for (const [className, frames] of byClass) {
1105
1206
  const characterId = charactersByClass.get(className);
1106
1207
  if (characterId === undefined)
@@ -1116,8 +1217,17 @@ function appendSwfAbcFrameScripts(state, timeline) {
1116
1217
  function appendSwfPendingTextShapes(reader, state) {
1117
1218
  for (const pending of state.pendingTexts) {
1118
1219
  const shape = createSwfTextShape(new SwfReader(reader.source, pending.start, pending.end), pending.version, state.fontOutlineSources);
1119
- if (shape !== null)
1120
- state.shapes.set(pending.characterId, shape);
1220
+ if (shape === null) {
1221
+ // Composition is deferred because the records address glyphs by index into a font that may not
1222
+ // have been read yet, so the failure lands here rather than at the tag. Without a report the
1223
+ // character is simply absent and every placement of it resolves to nothing.
1224
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.text-shape-uncomposable', 'appendSwfPendingTextShapes', {
1225
+ capability: pending.version === 2 ? 'swf.text.define-text-2' : 'swf.text.define-text',
1226
+ characterId: pending.characterId,
1227
+ });
1228
+ continue;
1229
+ }
1230
+ state.shapes.set(pending.characterId, shape);
1121
1231
  }
1122
1232
  }
1123
1233
  function readSwfTimeline(reader, state) {
@@ -1148,27 +1258,43 @@ function readSwfTimeline(reader, state) {
1148
1258
  frames.push(new Map(placements));
1149
1259
  }
1150
1260
  else if (code === TAG_DO_ABC || code === TAG_DO_ABC_ANONYMOUS) {
1151
- state.abcBlobs.push(readSwfAbcPayload(body, code === TAG_DO_ABC));
1261
+ state.abcBlobs.push({ bytes: readSwfAbcPayload(body, code === TAG_DO_ABC), named: code === TAG_DO_ABC });
1152
1262
  }
1153
1263
  else if (code === TAG_DO_INIT_ACTION) {
1154
1264
  // An init action runs once for the sprite it names, before that sprite's own first frame, so a
1155
1265
  // recognized block belongs to frame 1 of that sprite rather than to the timeline reading the tag.
1156
1266
  const spriteId = body.readUint16();
1157
1267
  const script = readSwfFrameActions(new SwfReader(body.source, body.pos, body.end));
1158
- if (script !== null)
1268
+ if (script === null) {
1269
+ // Same decline as DoAction below and the same Skip reasoning: the bytecode is outside this
1270
+ // importer's scope by charter rather than data it failed to read. It reports the sprite it names
1271
+ // rather than a frame, because an init action runs before that sprite's first frame.
1272
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.frame-script-declined', 'readSwfTimeline', { capability: 'swf.script.do-init-action', characterId: spriteId });
1273
+ }
1274
+ else {
1159
1275
  state.pendingInitActions.push({ characterId: spriteId, script });
1276
+ }
1160
1277
  }
1161
1278
  else if (code === TAG_DO_ACTION) {
1162
1279
  // A DoAction belongs to the frame being assembled — the one the next ShowFrame closes.
1163
1280
  const script = readSwfFrameActions(new SwfReader(body.source, body.pos, body.end));
1164
1281
  if (script !== null)
1165
1282
  actions.set(frames.length + 1, script);
1283
+ // A block is recognized only when EVERY action in it is a playback command, because honouring the
1284
+ // legible half would misrepresent what the frame does. Skip rather than Drop: the bytecode is
1285
+ // outside this importer's scope by charter, not data it failed to read.
1286
+ else {
1287
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.frame-script-declined', 'readSwfTimeline', {
1288
+ capability: 'swf.script.do-action',
1289
+ frame: frames.length + 1,
1290
+ });
1291
+ }
1166
1292
  }
1167
1293
  else if (code === TAG_FRAME_LABEL) {
1168
1294
  addSwfTimelineLabel(labels, frames.length + 1, body.readString());
1169
1295
  }
1170
1296
  else if (code === TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA) {
1171
- readSwfSceneAndFrameLabelData(body, labels);
1297
+ readSwfSceneAndFrameLabelData(body, labels, state.diagnostics);
1172
1298
  }
1173
1299
  else if (code === TAG_DEFINE_SCALING_GRID) {
1174
1300
  readSwfScalingGrid(body, state);
@@ -1177,7 +1303,7 @@ function readSwfTimeline(reader, state) {
1177
1303
  readLegacyPlaceObject(body, placements);
1178
1304
  }
1179
1305
  else if (code === TAG_PLACE_OBJECT_2) {
1180
- readPlaceObject(body, placements, false);
1306
+ readPlaceObject(body, placements, false, state.diagnostics);
1181
1307
  }
1182
1308
  else if (code === TAG_REMOVE_OBJECT) {
1183
1309
  readLegacyRemoveObject(body, placements);
@@ -1238,7 +1364,7 @@ function readSwfTimeline(reader, state) {
1238
1364
  state.sprites.set(spriteId, spriteTimeline);
1239
1365
  }
1240
1366
  else if (code === TAG_PLACE_OBJECT_3 || code === TAG_PLACE_OBJECT_4) {
1241
- readPlaceObject(body, placements, true);
1367
+ readPlaceObject(body, placements, true, state.diagnostics);
1242
1368
  }
1243
1369
  else if (code === TAG_START_SOUND) {
1244
1370
  readSwfStartSound(body, state, cues, frames.length + 1);
@@ -1263,6 +1389,15 @@ function readSwfTimeline(reader, state) {
1263
1389
  if (body.valid && body.pos < body.end)
1264
1390
  streamChunks.push(body.source.subarray(body.pos, body.end));
1265
1391
  }
1392
+ else if (streamFormat >= 0) {
1393
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.stream-sound-format', 'readSwfTimeline', {
1394
+ capability: 'swf.axis.sound-format-non-mp3',
1395
+ format: streamFormat,
1396
+ });
1397
+ }
1398
+ }
1399
+ else {
1400
+ reportSwfDeclinedTag(state.diagnostics, code);
1266
1401
  }
1267
1402
  if (!body.valid)
1268
1403
  return null;
@@ -1273,15 +1408,46 @@ function readSwfTimeline(reader, state) {
1273
1408
  if (frames.length === 0)
1274
1409
  frames.push(placements);
1275
1410
  appendSwfStreamSoundCue(state, cues, streamChunks, streamStartFrame);
1411
+ // A label or cue authored after the last ShowFrame names a frame the timeline never reaches, so it is
1412
+ // dropped rather than bound to a frame that does not exist. Found by auditing the claim rather than the
1413
+ // wire: `swf.scene-names` covered only the scene table, while THIS is the other way that tag's data is
1414
+ // lost, and silence about it was being reported as trustworthy.
1415
+ const reachableCues = cues.filter((cue) => cue.frame <= frames.length);
1416
+ const reachableLabels = labels.filter((label) => label.frame <= frames.length);
1417
+ if (reachableLabels.length !== labels.length) {
1418
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.label-past-last-frame', 'readSwfTimeline', {
1419
+ capability: 'swf.timeline.frame-label',
1420
+ dropped: labels.length - reachableLabels.length,
1421
+ frames: frames.length,
1422
+ });
1423
+ }
1424
+ if (reachableCues.length !== cues.length) {
1425
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.cue-past-last-frame', 'readSwfTimeline', {
1426
+ dropped: cues.length - reachableCues.length,
1427
+ frames: frames.length,
1428
+ });
1429
+ }
1276
1430
  return {
1277
1431
  actions,
1278
- // A cue authored after the last ShowFrame names a frame the timeline never reaches.
1279
- cues: cues.filter((cue) => cue.frame <= frames.length),
1432
+ cues: reachableCues,
1280
1433
  frames,
1281
- // A label declared after the last ShowFrame names a frame the timeline never reaches.
1282
- labels: labels.filter((label) => label.frame <= frames.length).sort(compareSwfTimelineLabelFrame),
1434
+ labels: reachableLabels.sort(compareSwfTimelineLabelFrame),
1283
1435
  };
1284
1436
  }
1437
+ // Reports the tags this importer deliberately reads past that cost a real capability, and stays silent on
1438
+ // the ones that cost nothing. The line matters: a document is not worse off for having skipped
1439
+ // `FileAttributes` or a font's hinting table, so reporting those would bury the entries that do mean
1440
+ // something under noise a caller has to filter. Every code below is a decision recorded in
1441
+ // `agents/packages/swf/tag-coverage.md`, not an unrecognized tag.
1442
+ function reportSwfDeclinedTag(diagnostics, code) {
1443
+ if (diagnostics === undefined)
1444
+ return;
1445
+ const kind = SWF_DECLINED_TAG_KINDS.get(code);
1446
+ if (kind === undefined)
1447
+ return;
1448
+ const capability = SWF_DECLINED_TAG_CAPABILITIES.get(code);
1449
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, kind, 'readSwfTimeline', capability === undefined ? { tag: code } : { capability, tag: code });
1450
+ }
1285
1451
  // The stage colour, as an RGB record. SWF gives it no alpha, and a stage is opaque, so it packs to fully
1286
1452
  // opaque RGBA. Last declaration wins, matching how a player applies the most recent one it has read.
1287
1453
  // A button is a display character whose content is a small display list per interaction state. A document
@@ -1312,6 +1478,14 @@ function readSwfButtonDefinition(body, state, version) {
1312
1478
  const colorTransform = version === 2 ? readSwfColorTransform(reader) : null;
1313
1479
  if (!reader.valid)
1314
1480
  return;
1481
+ if ((flags & BUTTON_STATE_UP) === 0 && characterId !== 0) {
1482
+ // A document is a still scene, so only the up state is held. The other states are a real capability
1483
+ // gap rather than data this decoder could not read, which is why they Skip rather than Drop.
1484
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.button-interaction-state', 'readSwfButtonDefinition', {
1485
+ characterId,
1486
+ flags,
1487
+ });
1488
+ }
1315
1489
  if ((flags & BUTTON_STATE_UP) !== 0 && characterId !== 0) {
1316
1490
  placements.set(depth, {
1317
1491
  advancedBlendMode: null,
@@ -1348,9 +1522,28 @@ function readSwfFontDefinition(body, state, code) {
1348
1522
  const version = code === TAG_DEFINE_FONT ? 1 : code === TAG_DEFINE_FONT_2 ? 2 : 3;
1349
1523
  const reader = new SwfReader(body.source, body.pos, body.end);
1350
1524
  const fontId = reader.source[body.pos] + reader.source[body.pos + 1] * 0x100;
1351
- const source = readSwfFontGlyphOutlineSource(reader, version);
1352
- if (source === null || fontId === 0)
1525
+ const source = readSwfFontGlyphOutlineSource(reader, version, state.diagnostics, fontId);
1526
+ // A glyph table this decoder cannot read at all costs the WHOLE font, not one glyph, so it is a
1527
+ // separate loss from `swf.font-glyph-outline` and must report separately: without this, a font that
1528
+ // vanished entirely and a font that imported cleanly both produce no crumb.
1529
+ if (source === null) {
1530
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.font-glyph-table', 'readSwfFontDefinition', {
1531
+ capability: version === 1 ? 'swf.font.define-font' : `swf.font.define-font-${version}`,
1532
+ characterId: fontId,
1533
+ });
1534
+ return;
1535
+ }
1536
+ if (fontId === 0)
1353
1537
  return;
1538
+ if (state.fontOutlineSources.has(fontId)) {
1539
+ // Every other definition kind rejects the document on a repeated character id; fonts do not, so the
1540
+ // second table silently replaces the first. The document imports, the font exists, and it is the
1541
+ // wrong font — no existence check and no count can see that, which is why it is reported here.
1542
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.font-character-id-reused', 'readSwfFontDefinition', {
1543
+ capability: version === 1 ? 'swf.font.define-font' : `swf.font.define-font-${version}`,
1544
+ characterId: fontId,
1545
+ });
1546
+ }
1354
1547
  state.fontOutlineSources.set(fontId, source);
1355
1548
  const fontName = readSwfFontName(body, version);
1356
1549
  if (fontName !== '')
@@ -1438,12 +1631,21 @@ function readSwfScalingGrid(body, state) {
1438
1631
  return;
1439
1632
  state.scalingGrids.set(characterId, splitter);
1440
1633
  }
1441
- function readSwfSceneAndFrameLabelData(body, labels) {
1634
+ function readSwfSceneAndFrameLabelData(body, labels, diagnostics) {
1442
1635
  const sceneCount = body.readEncodedUint32();
1443
1636
  for (let i = 0; i < sceneCount && body.valid; i++) {
1444
1637
  body.readEncodedUint32();
1445
1638
  body.readString();
1446
1639
  }
1640
+ // Scene names are read past to reach the label table behind them. Skip rather than Drop: Flight has
1641
+ // frame labels but no subject for a named frame range, so this is a capability gap rather than data
1642
+ // this decoder failed to read.
1643
+ if (sceneCount > 0) {
1644
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'swf.scene-names', 'readSwfSceneAndFrameLabelData', {
1645
+ capability: 'swf.timeline.define-scene-and-frame-label-data',
1646
+ sceneCount,
1647
+ });
1648
+ }
1447
1649
  const labelCount = body.readEncodedUint32();
1448
1650
  for (let i = 0; i < labelCount && body.valid; i++) {
1449
1651
  const frame = body.readEncodedUint32();
@@ -1494,8 +1696,12 @@ function readSwfBoundedDefinition(body, state, code) {
1494
1696
  const reader = new SwfReader(body.source, body.pos, body.end);
1495
1697
  const bounds = state.characterBounds.get(characterId);
1496
1698
  const factory = readSwfEditTextFactory(reader, bounds?.width ?? 0, bounds?.height ?? 0);
1497
- if (factory !== null)
1699
+ if (factory === null) {
1700
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.edit-text-unparseable', 'readSwfBoundedDefinition', { capability: 'swf.text.define-edit-text', characterId });
1701
+ }
1702
+ else {
1498
1703
  state.editTexts.set(characterId, factory);
1704
+ }
1499
1705
  }
1500
1706
  if (code === TAG_DEFINE_TEXT || code === TAG_DEFINE_TEXT_2) {
1501
1707
  const reader = new SwfReader(body.source, body.pos, body.end);
@@ -1526,8 +1732,17 @@ function readSwfShapeBody(body, state, characterId, version) {
1526
1732
  // The fill's character is not this shape's, hence the distinct name: a bitmap fill names whatever
1527
1733
  // character carries its pixels, which may be defined later in the tag stream.
1528
1734
  const shape = createSwfShape(reader, version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed));
1529
- if (shape !== null)
1530
- state.shapes.set(characterId, shape);
1735
+ if (shape === null) {
1736
+ // Recover rather than Drop: the character survives as the bounded placeholder it was before any
1737
+ // geometry existed, so the document still places and sizes it — only the drawing is missing.
1738
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Recover, 'swf.shape-body-unreadable', 'readSwfShapeDefinition', {
1739
+ capability: version === 1 ? 'swf.shape.define-shape' : `swf.shape.define-shape-${version}`,
1740
+ characterId,
1741
+ version,
1742
+ });
1743
+ return;
1744
+ }
1745
+ state.shapes.set(characterId, shape);
1531
1746
  }
1532
1747
  // Decodes a morph definition's geometry and paint on a reader of its own, so a body this decoder cannot
1533
1748
  // read costs only that body. The definition keeps its authored bounds and contributes no drawing, which
@@ -1539,9 +1754,17 @@ function readSwfMorphShapeBody(body, state, characterId, version) {
1539
1754
  const source = body.source;
1540
1755
  const start = body.pos;
1541
1756
  const end = body.end;
1542
- const decode = () => createSwfMorphShape(new SwfReader(source, start, end), version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed));
1543
- if (decode() !== null)
1544
- state.morphShapes.set(characterId, decode);
1757
+ // Only the parse-time validation call carries the sink. The stored closure runs again per placement,
1758
+ // potentially long after import returned, and a sink written to then would append to a collection
1759
+ // whose consumer has already read it.
1760
+ const decode = (diagnostics) => createSwfMorphShape(new SwfReader(source, start, end), version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed), diagnostics);
1761
+ if (decode(state.diagnostics) === null) {
1762
+ // The character is simply absent from the document afterwards, so without this the loss has no
1763
+ // signal at all: a placement of it resolves to nothing and the import still reports success.
1764
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.morph-shape-undecodable', 'readSwfMorphShapeBody', { capability: version === 2 ? 'swf.morph.define-morph-shape-2' : 'swf.morph.define-morph-shape', characterId });
1765
+ return;
1766
+ }
1767
+ state.morphShapes.set(characterId, decode);
1545
1768
  }
1546
1769
  function resolveSwfMorphShapeVersion(code) {
1547
1770
  if (code === TAG_DEFINE_MORPH_SHAPE)
@@ -1569,8 +1792,13 @@ function readSwfLegacyImageDefinition(body, state) {
1569
1792
  if (!body.valid || characterId === 0 || state.definedCharacters.has(characterId))
1570
1793
  return;
1571
1794
  const tables = state.jpegTables;
1572
- if (tables === null)
1795
+ if (tables === null) {
1796
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-tables-missing', 'readSwfLegacyImageDefinition', {
1797
+ capability: 'swf.bitmap.define-bits-jpeg-tables',
1798
+ characterId,
1799
+ });
1573
1800
  return;
1801
+ }
1574
1802
  const tablesEnd = tables.length >= 2 && tables[tables.length - 2] === 0xff && tables[tables.length - 1] === JPEG_END_OF_IMAGE
1575
1803
  ? tables.length - 2
1576
1804
  : tables.length;
@@ -1579,8 +1807,13 @@ function readSwfLegacyImageDefinition(body, state) {
1579
1807
  spliced.set(tables.subarray(0, tablesEnd));
1580
1808
  spliced.set(body.source.subarray(imageStart, body.end), tablesEnd);
1581
1809
  const image = readSwfEmbeddedImage(spliced, 0, spliced.length);
1582
- if (image === null)
1810
+ if (image === null) {
1811
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-tables-unsplittable', 'readSwfLegacyImageDefinition', {
1812
+ capability: 'swf.bitmap.define-bits-jpeg-tables',
1813
+ characterId,
1814
+ });
1583
1815
  return;
1816
+ }
1584
1817
  state.definedCharacters.add(characterId);
1585
1818
  state.characterBounds.set(characterId, image.bounds);
1586
1819
  state.images.set(characterId, { bytes: spliced, mimeType: image.mimeType });
@@ -1599,6 +1832,16 @@ function readSwfEmbeddedImageDefinition(body, state, code) {
1599
1832
  body.readUint16();
1600
1833
  imageStart = body.pos;
1601
1834
  imageEnd = alphaOffsetBase + alphaDataOffset;
1835
+ // The colour stream ends at the alpha offset and the zlib-compressed alpha block after it is
1836
+ // discarded, so a transparent JPEG imports fully opaque. Drop rather than Skip: this is authored
1837
+ // data lost, not a feature declined — the bytes are present and go unread.
1838
+ if (alphaDataOffset > 0 && imageEnd < body.end) {
1839
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-alpha-stream', 'readSwfEmbeddedImageDefinition', {
1840
+ capability: code === TAG_DEFINE_BITS_JPEG_3 ? 'swf.bitmap.define-bits-jpeg-3' : 'swf.bitmap.define-bits-jpeg-4',
1841
+ characterId,
1842
+ discardedBytes: body.end - imageEnd,
1843
+ });
1844
+ }
1602
1845
  }
1603
1846
  if (!body.valid ||
1604
1847
  characterId === 0 ||
@@ -2030,8 +2273,6 @@ const S_SIGNATURE = 0x53;
2030
2273
  const MORPH_RATIO_ONE = 0xffff;
2031
2274
  const SWF_INSTANCE_KEY_SCALE = 0x10000;
2032
2275
  const SWF_LZMA_PREFIX_LENGTH = 17;
2033
- const SWF_LOSSLESS_ALPHA_MIME_TYPE = 'image/x-swf-lossless-alpha';
2034
- const SWF_LOSSLESS_MIME_TYPE = 'image/x-swf-lossless';
2035
2276
  const SWF_MIME_TYPE = 'application/x-shockwave-flash';
2036
2277
  const SWF_PREFIX_LENGTH = 8;
2037
2278
  const SOUND_ENVELOPE_LEVEL_ONE = 32768;
@@ -2065,6 +2306,22 @@ const TAG_START_SOUND_2 = 89;
2065
2306
  const TAG_SOUND_STREAM_BLOCK = 19;
2066
2307
  const TAG_SOUND_STREAM_HEAD = 18;
2067
2308
  const TAG_SOUND_STREAM_HEAD_2 = 45;
2309
+ // Only tags whose absence loses something a caller could want. Metadata tags — `FileAttributes`,
2310
+ // `Metadata`, `ProductInfo`, `ScriptLimits`, `DebugID`, `EnableDebugger2`, `EnableTelemetry`, `Protect`,
2311
+ // `SetTabIndex`, `DefineButtonCxform`, and the font hinting/naming tables — are deliberately absent: they
2312
+ // carry no scene content, so skipping one is not a loss to report.
2313
+ // The declared capability a declined tag costs, where one exists. Deliberately partial: `DefineFont4`,
2314
+ // `DefineBinaryData`, `ImportAssets` and `DefineButtonSound` name no declared capability, and inventing
2315
+ // one so every crumb could carry a join key would put entries in the denominator that nothing measures.
2316
+ const SWF_DECLINED_TAG_CAPABILITIES = new Map([[61, 'swf.video.video-frame']]);
2317
+ const SWF_DECLINED_TAG_KINDS = new Map([
2318
+ [17, 'swf.define-button-sound'],
2319
+ [57, 'swf.import-assets'],
2320
+ [61, 'swf.video-frame-payload'],
2321
+ [71, 'swf.import-assets'],
2322
+ [87, 'swf.define-binary-data'],
2323
+ [91, 'swf.define-font-4'],
2324
+ ]);
2068
2325
  const TAG_DEFINE_SCALING_GRID = 78;
2069
2326
  const TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA = 86;
2070
2327
  const TAG_DEFINE_BITS_JPEG_2 = 21;