@flighthq/swf 0.3.0 → 0.3.1-next.1041.35b950c

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 (61) hide show
  1. package/dist/contract.d.ts +2 -0
  2. package/dist/contract.d.ts.map +1 -1
  3. package/dist/contract.js +2 -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/swfBitmap.d.ts +2 -1
  10. package/dist/swfBitmap.d.ts.map +1 -1
  11. package/dist/swfBitmap.js +30 -3
  12. package/dist/swfBitmap.js.map +1 -1
  13. package/dist/swfDocument.d.ts +5 -5
  14. package/dist/swfDocument.d.ts.map +1 -1
  15. package/dist/swfDocument.js +404 -108
  16. package/dist/swfDocument.js.map +1 -1
  17. package/dist/swfEditText.d.ts.map +1 -1
  18. package/dist/swfEditText.js +6 -4
  19. package/dist/swfEditText.js.map +1 -1
  20. package/dist/swfFilter.d.ts +2 -2
  21. package/dist/swfFilter.d.ts.map +1 -1
  22. package/dist/swfFilter.js +24 -2
  23. package/dist/swfFilter.js.map +1 -1
  24. package/dist/swfFrameAction.d.ts +2 -2
  25. package/dist/swfFrameAction.d.ts.map +1 -1
  26. package/dist/swfFrameAction.js +13 -6
  27. package/dist/swfFrameAction.js.map +1 -1
  28. package/dist/swfFrameActionTestHelper.d.ts +2 -0
  29. package/dist/swfFrameActionTestHelper.d.ts.map +1 -0
  30. package/dist/swfFrameActionTestHelper.js +99 -0
  31. package/dist/swfFrameActionTestHelper.js.map +1 -0
  32. package/dist/swfImageDecoder.d.ts +4 -0
  33. package/dist/swfImageDecoder.d.ts.map +1 -0
  34. package/dist/swfImageDecoder.js +21 -0
  35. package/dist/swfImageDecoder.js.map +1 -0
  36. package/dist/swfMorphShape.d.ts +2 -1
  37. package/dist/swfMorphShape.d.ts.map +1 -1
  38. package/dist/swfMorphShape.js +18 -7
  39. package/dist/swfMorphShape.js.map +1 -1
  40. package/dist/swfShape.d.ts +2 -2
  41. package/dist/swfShape.d.ts.map +1 -1
  42. package/dist/swfShape.js +26 -7
  43. package/dist/swfShape.js.map +1 -1
  44. package/dist/swfShapeTestHelper.d.ts +1 -0
  45. package/dist/swfShapeTestHelper.d.ts.map +1 -1
  46. package/dist/swfShapeTestHelper.js +14 -0
  47. package/dist/swfShapeTestHelper.js.map +1 -1
  48. package/dist/swfText.d.ts +2 -2
  49. package/dist/swfText.d.ts.map +1 -1
  50. package/dist/swfText.js +15 -2
  51. package/dist/swfText.js.map +1 -1
  52. package/package.json +25 -22
  53. package/src/swfBitmap.test.ts +106 -5
  54. package/src/swfDocument.test.ts +1639 -125
  55. package/src/swfEditText.test.ts +45 -5
  56. package/src/swfFilter.test.ts +53 -0
  57. package/src/swfFrameAction.test.ts +26 -97
  58. package/src/swfImageDecoder.test.ts +73 -0
  59. package/src/swfImageResources.test.ts +14 -17
  60. package/src/swfMorphShape.test.ts +18 -0
  61. package/src/swfShape.test.ts +47 -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, CompressionFraming, 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,17 +47,21 @@ 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
+ }
60
+ const imageResources = createSwfImageResources(parsed);
57
61
  return {
58
62
  appearances: instantiation.appearances,
59
- document: createScene2DDocument(root, slots, 'swf', parsed.backgroundColor, createSwfImageResources(parsed), createSwfAudioResources(parsed)),
63
+ document: createScene2DDocument(root, slots, 'swf', parsed.backgroundColor, imageResources.resources, createSwfAudioResources(parsed)),
64
+ jpegAlphaPayloads: createSwfJpegAlphaPayloads(parsed, imageResources.references),
60
65
  };
61
66
  }
62
67
  // Instantiates a symbol the file exported by linkage name but never placed on a timeline. A library
@@ -70,8 +75,8 @@ export function createScene2DImportFromSwf(source) {
70
75
  // Anything less would hand back artwork whose bitmaps could never be paired with their pixels, since
71
76
  // each call parses afresh and its Textures are its own. `backgroundColor` stays null: the stage colour
72
77
  // 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);
78
+ export function createScene2DSymbolFromSwf(source, linkageName, diagnostics) {
79
+ const file = readSwfFile(source, diagnostics);
75
80
  if (file === null)
76
81
  return null;
77
82
  const { frameRate, parsed } = file;
@@ -80,12 +85,19 @@ export function createScene2DSymbolFromSwf(source, linkageName) {
80
85
  if (name === linkageName)
81
86
  characterId = id;
82
87
  }
83
- if (characterId < 0)
88
+ // A linkage name nothing exports is the caller naming a symbol this file does not have — worth telling
89
+ // them apart from a file that failed to parse, since both come back null.
90
+ if (characterId < 0) {
91
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.unknown-linkage-name', 'createScene2DSymbolFromSwf', {
92
+ linkageName,
93
+ });
84
94
  return null;
95
+ }
85
96
  const slots = [];
86
97
  const instantiation = {
87
98
  activeSymbols: new Set(),
88
99
  appearances: [],
100
+ diagnostics,
89
101
  frameRate: frameRate > 0 ? frameRate : null,
90
102
  resolvingBounds: new Set(),
91
103
  resolvedBounds: new Map(),
@@ -93,8 +105,7 @@ export function createScene2DSymbolFromSwf(source, linkageName) {
93
105
  const root = createSwfSymbolNode(parsed, characterId, slots, instantiation);
94
106
  if (root === null)
95
107
  return null;
96
- fillSwfLosslessBitmapTextures(parsed);
97
- return createScene2DDocument(root, slots, 'swf', null, createSwfImageResources(parsed), createSwfAudioResources(parsed));
108
+ return createScene2DDocument(root, slots, 'swf', null, createSwfImageResources(parsed).resources, createSwfAudioResources(parsed));
98
109
  }
99
110
  // Every linkage name the file exported, whether or not the symbol was ever placed. Pair with
100
111
  // `createScene2DSymbolFromSwf` to instantiate one.
@@ -105,58 +116,51 @@ export function readSwfExportedSymbolNames(source) {
105
116
  export function registerSwfScene2DDocumentImporter(registry) {
106
117
  registerScene2DDocumentImporter(registry, 'swf', matchesSwfDocument, (source) => createScene2DFromSwf(source));
107
118
  }
108
- function readSwfFile(source) {
109
- const uncompressed = uncompressSwfSource(source);
119
+ function readSwfFile(source, diagnostics) {
120
+ const uncompressed = uncompressSwfSource(source, diagnostics);
110
121
  if (uncompressed === null)
111
122
  return null;
123
+ // Every rejection below loses the WHOLE document, and each has a distinct cause. Without a report per
124
+ // cause the caller receives one null from `createScene2DFromSwf` and cannot tell an unregistered
125
+ // decompressor — which IS reported, upstream — from a truncated header, which was not.
112
126
  const header = new SwfReader(uncompressed, 0, uncompressed.length);
113
127
  const signature = header.readUint8();
114
128
  if (signature !== FWS_SIGNATURE || header.readUint8() !== W_SIGNATURE || header.readUint8() !== S_SIGNATURE) {
129
+ // Distinct from the container's `swf.invalid-signature`: that one reads the ORIGINAL bytes, this one
130
+ // reads the decompressed body, whose header the decompressor rewrote. NOTHING CAN REACH THIS TODAY —
131
+ // an FWS container is returned unchanged and every compressed path writes `FWS_SIGNATURE` into byte 0,
132
+ // while bytes 1 and 2 were validated before either. The report stays because unreachable-by-
133
+ // construction is a property of today's two container paths, not of the format: a third path, or a
134
+ // decompressor that stops rewriting the header, makes it reachable, and silence would return with it.
135
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.uncompressed-signature-invalid', 'readSwfFile');
115
136
  return null;
116
137
  }
117
138
  const version = header.readUint8();
118
139
  const fileLength = header.readUint32();
119
- if (!header.valid || version === 0 || fileLength < MIN_SWF_LENGTH || fileLength > uncompressed.length)
140
+ if (!header.valid || version === 0 || fileLength < MIN_SWF_LENGTH || fileLength > uncompressed.length) {
141
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.header-fields-invalid', 'readSwfFile', {
142
+ declaredLength: fileLength,
143
+ available: uncompressed.length,
144
+ version,
145
+ });
120
146
  return null;
147
+ }
121
148
  const body = new SwfReader(uncompressed, SWF_PREFIX_LENGTH, fileLength);
122
149
  const stageBounds = readSwfRectangle(body);
123
- if (stageBounds === null)
150
+ if (stageBounds === null) {
151
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.stage-bounds-unreadable', 'readSwfFile');
124
152
  return null;
153
+ }
125
154
  // Header FrameRate is 8.8 fixed and governs every timeline in the file; the authored FrameCount that
126
155
  // follows it is advisory, so the real root frame count comes from the ShowFrame tags themselves.
127
156
  const frameRate = body.readUint16() / FIXED_8_8_ONE;
128
157
  body.readUint16();
129
- if (!body.valid)
158
+ if (!body.valid) {
159
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.header-truncated', 'readSwfFile');
130
160
  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
161
  }
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;
162
+ const parsed = readSwfTags(body, diagnostics);
163
+ return parsed === null ? null : { frameRate, parsed, stageBounds };
160
164
  }
161
165
  // Presents any container form as the uncompressed bytes the rest of the importer reads. `FWS` is already
162
166
  // that and is returned as-is, with no copy. `CWS` and `ZWS` compress everything after the 8-byte header,
@@ -164,32 +168,61 @@ function isSwfLosslessImage(image) {
164
168
  // to `FWS` — the declared length already counts uncompressed bytes, so it carries over untouched.
165
169
  // Compression the caller has not registered a decompressor for is reported as the document's null
166
170
  // 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)
171
+ function uncompressSwfSource(source, diagnostics) {
172
+ if (source.length < SWF_PREFIX_LENGTH || source[1] !== W_SIGNATURE || source[2] !== S_SIGNATURE) {
173
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.invalid-signature', 'uncompressSwfSource', {
174
+ length: source.length,
175
+ });
169
176
  return null;
177
+ }
170
178
  const signature = source[0];
171
179
  if (signature === FWS_SIGNATURE)
172
180
  return source;
173
181
  const compression = signature === CWS_SIGNATURE ? Compression.Deflate : signature === ZWS_SIGNATURE ? Compression.Lzma : null;
174
- if (compression === null)
182
+ if (compression === null) {
183
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.unknown-container', 'uncompressSwfSource', {
184
+ signature,
185
+ });
175
186
  return null;
187
+ }
176
188
  const decompress = getDecompressor(compression);
177
- if (decompress === null)
189
+ // Distinct from a malformed body on purpose. Both return the same null sentinel, but a caller that
190
+ // never registered a decompressor has a file it could read after one registration, while a corrupt
191
+ // stream is unreadable however the caller is configured — and only the crumb can tell them apart.
192
+ if (decompress === null) {
193
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.no-decompressor-registered', 'uncompressSwfSource', { compression });
178
194
  return null;
195
+ }
179
196
  const header = new SwfReader(source, 0, SWF_PREFIX_LENGTH);
180
197
  header.readUint32();
181
198
  const fileLength = header.readUint32();
182
- if (fileLength < MIN_SWF_LENGTH)
199
+ if (fileLength < MIN_SWF_LENGTH) {
200
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.declared-length-too-small', 'uncompressSwfSource', {
201
+ fileLength,
202
+ });
183
203
  return null;
204
+ }
184
205
  // LZMA puts a compressed length and the 5 property bytes between the header and its stream; zlib starts
185
206
  // its stream immediately. Either way the decompressor receives the stream itself.
186
207
  const bodyLength = fileLength - SWF_PREFIX_LENGTH;
187
208
  const streamStart = compression === Compression.Lzma ? SWF_LZMA_PREFIX_LENGTH : SWF_PREFIX_LENGTH;
188
- if (streamStart > source.length)
209
+ if (streamStart > source.length) {
210
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.truncated-container', 'uncompressSwfSource', {
211
+ length: source.length,
212
+ streamStart,
213
+ });
189
214
  return null;
190
- const body = decompress(source.subarray(streamStart), bodyLength);
191
- if (body === null || body.length < bodyLength)
215
+ }
216
+ const framing = compression === Compression.Deflate ? CompressionFraming.Rfc1950 : CompressionFraming.Raw;
217
+ const body = decompress(source.subarray(streamStart), bodyLength, framing);
218
+ if (body === null || body.length < bodyLength) {
219
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'swf.decompression-failed', 'uncompressSwfSource', {
220
+ compression,
221
+ expected: bodyLength,
222
+ received: body === null ? -1 : body.length,
223
+ });
192
224
  return null;
225
+ }
193
226
  const uncompressed = new Uint8Array(SWF_PREFIX_LENGTH + bodyLength);
194
227
  uncompressed.set(source.subarray(0, SWF_PREFIX_LENGTH));
195
228
  uncompressed[0] = FWS_SIGNATURE;
@@ -229,7 +262,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
229
262
  const clips = new Map();
230
263
  for (const frame of timeline.frames) {
231
264
  const ordered = [...frame.values()].sort(compareSwfPlacementDepth);
232
- frames.push(buildSwfFrameEntries(ordered, parsed, clips));
265
+ frames.push(buildSwfFrameEntries(ordered, parsed, clips, state.diagnostics));
233
266
  for (const placement of ordered) {
234
267
  const key = createSwfInstanceKey(placement);
235
268
  if (nodes.has(key))
@@ -268,7 +301,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
268
301
  const target = scale9 !== null
269
302
  ? scale9
270
303
  : editText !== undefined
271
- ? createSwfEditTextTarget(editText, parsed, targetBounds)
304
+ ? createSwfEditTextTarget(editText, parsed, targetBounds, state.diagnostics)
272
305
  : image !== undefined
273
306
  ? createSwfTexturedSprite(acquireSwfImageTexture(parsed, placement.characterId, false, true), targetBounds)
274
307
  : video !== undefined
@@ -291,7 +324,7 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
291
324
  }
292
325
  }
293
326
  }
294
- collectSwfNodeAppearances(frames, nodes, state.appearances);
327
+ collectSwfNodeAppearances(frames, nodes, state.appearances, state.diagnostics);
295
328
  setMovieClipSource(clip, createSwfTimelineSource(frames, nodes, timeline.labels, timeline.cues, state.frameRate));
296
329
  // Frame scripts attach after the source, so the clip already knows how many frames it has when a
297
330
  // recognized command addresses one.
@@ -307,15 +340,24 @@ function populateSwfTimelineNode(clip, timeline, parsed, slots, state, depth) {
307
340
  //
308
341
  // One entry per (instance, frame) that carries either, so a filter that changes across frames reads as
309
342
  // the per-frame data it is. Frames are 1-based, matching gotoAndStopMovieClip.
310
- function collectSwfNodeAppearances(frames, nodes, out) {
343
+ function collectSwfNodeAppearances(frames, nodes, out, diagnostics) {
311
344
  for (let frame = 0; frame < frames.length; frame++) {
312
345
  for (const entry of frames[frame]) {
313
346
  const { advancedBlendMode, effects } = entry.placement;
314
347
  if (advancedBlendMode === null && effects.length === 0)
315
348
  continue;
316
349
  const node = nodes.get(createSwfInstanceKey(entry.placement));
317
- if (node === undefined)
350
+ if (node === undefined) {
351
+ // The appearance report is the only carrier for these two channels, so a placement whose node was
352
+ // never allocated loses them outright. Reaching here means the placement declared one or both,
353
+ // which is why the earlier `continue` is not a loss and this one is. A masking placement cannot
354
+ // reach here despite also earning no node: masks are excluded from frame entries upstream.
355
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.appearance-without-node', 'collectSwfNodeAppearances', {
356
+ capability: advancedBlendMode !== null ? 'swf.placement.blend-mode' : 'swf.placement.filter-list',
357
+ frame: frame + 1,
358
+ });
318
359
  continue;
360
+ }
319
361
  out.push({ advancedBlendMode, effects: [...effects], frame: frame + 1, node });
320
362
  }
321
363
  }
@@ -424,28 +466,51 @@ function createSwfTimelineSource(frames, nodes, labels, cues, frameRate) {
424
466
  // placement with a clip depth covers every depth above its own through that clip depth — while Flight
425
467
  // clips a node and its subtree. Applying one region to each covered sibling is equivalent to grouping
426
468
  // them under a clipped container, and it leaves the attach/detach/reorder path untouched.
427
- function buildSwfFrameEntries(ordered, parsed, clips) {
469
+ function buildSwfFrameEntries(ordered, parsed, clips, diagnostics) {
428
470
  const entries = [];
429
471
  for (const placement of ordered) {
430
472
  if (placement.clipDepth > 0)
431
473
  continue;
432
- const mask = resolveSwfPlacementMask(ordered, placement);
433
- entries.push({ clip: mask === null ? null : resolveSwfMaskClip(mask, placement, parsed, clips), placement });
474
+ const mask = resolveSwfPlacementMask(ordered, placement, diagnostics);
475
+ const clip = mask === null ? null : resolveSwfMaskClip(mask, placement, parsed, clips);
476
+ // A mask that resolves to no region leaves its covered instance UNCLIPPED, which is a visible
477
+ // difference and not a no-op: the mask character had no decoded geometry, so imposing nothing was
478
+ // the honest choice over imposing a wrong clip. Recover rather than Drop — the instance still draws.
479
+ if (mask !== null && clip === null) {
480
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'swf.mask-without-geometry', 'buildSwfFrameEntries', {
481
+ capability: 'swf.placement.clip-depth',
482
+ depth: placement.depth,
483
+ maskCharacterId: mask.characterId,
484
+ });
485
+ }
486
+ entries.push({ clip, placement });
434
487
  }
435
488
  return entries;
436
489
  }
437
490
  // The innermost mask covering a depth. Flight carries one clip per node, so where masks nest, the
438
491
  // deepest one wins rather than intersecting them.
439
- function resolveSwfPlacementMask(ordered, placement) {
492
+ function resolveSwfPlacementMask(ordered, placement, diagnostics) {
440
493
  let mask = null;
494
+ let covering = 0;
441
495
  for (const candidate of ordered) {
442
496
  if (candidate.clipDepth <= 0 || candidate.depth >= placement.depth)
443
497
  continue;
444
498
  if (placement.depth > candidate.clipDepth)
445
499
  continue;
500
+ covering++;
446
501
  if (mask === null || candidate.depth > mask.depth)
447
502
  mask = candidate;
448
503
  }
504
+ // Two masks over one instance means the outer one is not applied at all, so the instance shows more
505
+ // than the file said it should. Skip rather than Recover: a node carries one clip, so this is a
506
+ // vocabulary gap in the clip subject rather than geometry this decoder failed to read.
507
+ if (covering > 1) {
508
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'swf.nested-mask-collapsed', 'resolveSwfPlacementMask', {
509
+ capability: 'swf.placement.clip-depth',
510
+ covering,
511
+ depth: placement.depth,
512
+ });
513
+ }
449
514
  return mask;
450
515
  }
451
516
  function resolveSwfMaskClip(mask, placement, parsed, clips) {
@@ -581,18 +646,40 @@ function createSwfAudioResources(parsed) {
581
646
  return resources;
582
647
  }
583
648
  function createSwfImageResources(parsed) {
649
+ const references = new Map();
584
650
  const resources = [];
585
651
  for (const [characterId, variants] of parsed.imageTextures) {
586
652
  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))
653
+ if (image === undefined)
590
654
  continue;
591
- const reference = createEmbeddedImageResourceReference(image.bytes, image.mimeType);
655
+ const alphaType = image.mimeType === SWF_LOSSLESS_ALPHA_MIME_TYPE
656
+ ? 'premultiplied'
657
+ : image.mimeType === SWF_LOSSLESS_MIME_TYPE
658
+ ? 'opaque'
659
+ : 'straight';
660
+ const reference = createEmbeddedImageResourceReference(image.bytes, image.mimeType, alphaType);
592
661
  reference.textures = [...variants.values()];
662
+ references.set(characterId, reference);
593
663
  resources.push(reference);
594
664
  }
595
- return resources;
665
+ return { references, resources };
666
+ }
667
+ // Completes the full-import sidecar after document references exist. An unplaced definition still earns
668
+ // a report record and its own zero-texture reference; a placed one reuses the document's exact object.
669
+ function createSwfJpegAlphaPayloads(parsed, references) {
670
+ const payloads = [];
671
+ for (const [characterId, source] of parsed.jpegAlphaPayloads) {
672
+ let reference = references.get(characterId);
673
+ if (reference === undefined) {
674
+ const image = parsed.images.get(characterId);
675
+ if (image === undefined)
676
+ continue;
677
+ reference = createEmbeddedImageResourceReference(image.bytes, image.mimeType);
678
+ references.set(characterId, reference);
679
+ }
680
+ payloads.push({ ...source, reference });
681
+ }
682
+ return payloads;
596
683
  }
597
684
  function createSwfTexturedSprite(texture, bounds) {
598
685
  const target = createSprite();
@@ -624,8 +711,21 @@ function createSwfMovieClip(bounds) {
624
711
  // A field becomes its own node per placement, because its text is per-instance state rather than shared
625
712
  // artwork. The authored RECT still sizes it, so a field reports the box the tool drew even before any
626
713
  // layout has run.
627
- function createSwfEditTextTarget(create, parsed, bounds) {
628
- const node = create((fontId) => parsed.fontNames.get(fontId) ?? '');
714
+ function createSwfEditTextTarget(create, parsed, bounds, diagnostics) {
715
+ // The field survives with its size, box and colour and simply has no font family, which is the
716
+ // diminished case: it exists, it is smaller than authored, and nothing else says so. Reported once per
717
+ // unresolved id rather than per call, since the resolver runs for every run of text.
718
+ const unresolved = new Set();
719
+ const node = create((fontId) => {
720
+ const name = parsed.fontNames.get(fontId);
721
+ if (name !== undefined)
722
+ return name;
723
+ if (!unresolved.has(fontId)) {
724
+ unresolved.add(fontId);
725
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.edit-text-font-name-unresolved', 'createSwfEditTextTarget', { capability: 'swf.text.define-edit-text', fontId });
726
+ }
727
+ return '';
728
+ });
629
729
  if (bounds !== null) {
630
730
  node.data.authoredBounds = { ...bounds };
631
731
  getNodeRuntime(node).computeLocalBoundsRectangle = computeSwfLocalBoundsRectangle;
@@ -726,7 +826,7 @@ function createSwfSymbolNode(parsed, characterId, slots, state) {
726
826
  const bounds = resolveSwfCharacterBounds(parsed, characterId, state, 0);
727
827
  const editText = parsed.editTexts.get(characterId);
728
828
  if (editText !== undefined)
729
- return createSwfEditTextTarget(editText, parsed, bounds);
829
+ return createSwfEditTextTarget(editText, parsed, bounds, state.diagnostics);
730
830
  if (parsed.images.has(characterId)) {
731
831
  return createSwfTexturedSprite(acquireSwfImageTexture(parsed, characterId, false, true), bounds);
732
832
  }
@@ -759,16 +859,29 @@ function resolveSwfCharacterBounds(parsed, characterId, state, depth) {
759
859
  // A symbol's authored extent covers everything it can show, so this unions every frame's placements
760
860
  // rather than only the first frame's: the node's local bounds do not change as its playhead moves.
761
861
  let bounds = null;
862
+ let missingChildren = 0;
762
863
  for (const frame of sprite.frames) {
763
864
  for (const placement of frame.values()) {
764
865
  const childBounds = resolveSwfCharacterBounds(parsed, placement.characterId, state, depth + 1);
765
- if (childBounds === null)
866
+ if (childBounds === null) {
867
+ // Two different things reach here and only one is a loss. A sprite that resolved to an empty
868
+ // union has no extent to contribute and is correct; a character with neither authored bounds nor
869
+ // a sprite body was never imported at all, and the union it should have widened is short.
870
+ if (!parsed.characterBounds.has(placement.characterId) && !parsed.sprites.has(placement.characterId)) {
871
+ missingChildren++;
872
+ }
766
873
  continue;
874
+ }
767
875
  const transformed = transformSwfRectangle(childBounds, placement.matrix);
768
876
  bounds = bounds === null ? transformed : mergeSwfRectangles(bounds, transformed);
769
877
  }
770
878
  }
771
879
  state.resolvingBounds.delete(characterId);
880
+ if (missingChildren > 0) {
881
+ // A count rather than a flag: the box survives and is simply smaller than the sprite's contents, and
882
+ // a count is the only thing that separates a full union from a short one.
883
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.sprite-bounds-short', 'resolveSwfCharacterBounds', { capability: 'swf.timeline.define-sprite', characterId, missingChildren });
884
+ }
772
885
  state.resolvedBounds.set(characterId, bounds);
773
886
  return bounds;
774
887
  }
@@ -787,7 +900,7 @@ function transformSwfRectangle(bounds, matrix) {
787
900
  const maxY = Math.max(y0, y1, y2, y3);
788
901
  return { height: maxY - y, width: maxX - x, x, y };
789
902
  }
790
- function readPlaceObject(body, placements, hasExtendedFlags) {
903
+ function readPlaceObject(body, placements, hasExtendedFlags, diagnostics) {
791
904
  const flags = body.readUint8();
792
905
  const extendedFlags = hasExtendedFlags ? body.readUint8() : 0;
793
906
  const depth = body.readUint16();
@@ -811,8 +924,15 @@ function readPlaceObject(body, placements, hasExtendedFlags) {
811
924
  const hasFilterList = (extendedFlags & 0x01) !== 0;
812
925
  const readEffects = [];
813
926
  const readFilterAdjustments = [];
814
- const filterListComplete = !hasFilterList || readSwfFilterList(body, readEffects, readFilterAdjustments);
815
- const hasBlendMode = (extendedFlags & 0x02) !== 0 && filterListComplete;
927
+ const filterListComplete = !hasFilterList || readSwfFilterList(body, readEffects, readFilterAdjustments, diagnostics);
928
+ const declaresBlendMode = (extendedFlags & 0x02) !== 0;
929
+ const hasBlendMode = declaresBlendMode && filterListComplete;
930
+ if (declaresBlendMode && !filterListComplete) {
931
+ // The record declared a blend mode and the byte carrying it is unreachable behind a list that did
932
+ // not finish. The placement keeps the filters read so far, so nothing about the result says a
933
+ // declared channel went unread.
934
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'swf.blend-mode-behind-unread-filters', 'readPlaceObject', { capability: 'swf.placement.blend-mode' });
935
+ }
816
936
  const blendModeValue = hasBlendMode ? body.readUint8() : 0;
817
937
  if (!body.valid || (isMove && existing === undefined) || (characterId === 0 && directLinkage === null))
818
938
  return;
@@ -1006,10 +1126,11 @@ function readSwfRectangle(reader) {
1006
1126
  y: yMin / TWIPS_PER_PIXEL,
1007
1127
  };
1008
1128
  }
1009
- function readSwfTags(reader) {
1129
+ function readSwfTags(reader, diagnostics) {
1010
1130
  const state = {
1011
1131
  abcBlobs: [],
1012
1132
  backgroundColor: null,
1133
+ diagnostics,
1013
1134
  pendingInitActions: [],
1014
1135
  editTexts: new Map(),
1015
1136
  fontNames: new Map(),
@@ -1019,6 +1140,7 @@ function readSwfTags(reader) {
1019
1140
  fontOutlineSources: new Map(),
1020
1141
  images: new Map(),
1021
1142
  imageTextures: new Map(),
1143
+ jpegAlphaPayloads: new Map(),
1022
1144
  jpegTables: null,
1023
1145
  pendingTexts: [],
1024
1146
  linkages: new Map(),
@@ -1057,6 +1179,7 @@ function readSwfTags(reader) {
1057
1179
  fontOutlineSources: state.fontOutlineSources,
1058
1180
  images: state.images,
1059
1181
  imageTextures: state.imageTextures,
1182
+ jpegAlphaPayloads: state.jpegAlphaPayloads,
1060
1183
  linkages: state.linkages,
1061
1184
  morphBounds: state.morphBounds,
1062
1185
  morphShapes: state.morphShapes,
@@ -1098,9 +1221,11 @@ function appendSwfAbcFrameScripts(state, timeline) {
1098
1221
  for (const [characterId, className] of state.linkages)
1099
1222
  charactersByClass.set(className, characterId);
1100
1223
  for (const blob of state.abcBlobs) {
1101
- const byClass = readSwfAbcFrameScripts(blob);
1102
- if (byClass === null)
1224
+ const byClass = readSwfAbcFrameScripts(blob.bytes, state.diagnostics);
1225
+ if (byClass === null) {
1226
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.abc-frame-scripts-unreadable', 'appendSwfAbcFrameScripts', { capability: blob.named ? 'swf.script.do-abc' : 'swf.script.do-abc-anonymous' });
1103
1227
  continue;
1228
+ }
1104
1229
  for (const [className, frames] of byClass) {
1105
1230
  const characterId = charactersByClass.get(className);
1106
1231
  if (characterId === undefined)
@@ -1116,8 +1241,17 @@ function appendSwfAbcFrameScripts(state, timeline) {
1116
1241
  function appendSwfPendingTextShapes(reader, state) {
1117
1242
  for (const pending of state.pendingTexts) {
1118
1243
  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);
1244
+ if (shape === null) {
1245
+ // Composition is deferred because the records address glyphs by index into a font that may not
1246
+ // have been read yet, so the failure lands here rather than at the tag. Without a report the
1247
+ // character is simply absent and every placement of it resolves to nothing.
1248
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.text-shape-uncomposable', 'appendSwfPendingTextShapes', {
1249
+ capability: pending.version === 2 ? 'swf.text.define-text-2' : 'swf.text.define-text',
1250
+ characterId: pending.characterId,
1251
+ });
1252
+ continue;
1253
+ }
1254
+ state.shapes.set(pending.characterId, shape);
1121
1255
  }
1122
1256
  }
1123
1257
  function readSwfTimeline(reader, state) {
@@ -1148,27 +1282,43 @@ function readSwfTimeline(reader, state) {
1148
1282
  frames.push(new Map(placements));
1149
1283
  }
1150
1284
  else if (code === TAG_DO_ABC || code === TAG_DO_ABC_ANONYMOUS) {
1151
- state.abcBlobs.push(readSwfAbcPayload(body, code === TAG_DO_ABC));
1285
+ state.abcBlobs.push({ bytes: readSwfAbcPayload(body, code === TAG_DO_ABC), named: code === TAG_DO_ABC });
1152
1286
  }
1153
1287
  else if (code === TAG_DO_INIT_ACTION) {
1154
1288
  // An init action runs once for the sprite it names, before that sprite's own first frame, so a
1155
1289
  // recognized block belongs to frame 1 of that sprite rather than to the timeline reading the tag.
1156
1290
  const spriteId = body.readUint16();
1157
1291
  const script = readSwfFrameActions(new SwfReader(body.source, body.pos, body.end));
1158
- if (script !== null)
1292
+ if (script === null) {
1293
+ // Same decline as DoAction below and the same Skip reasoning: the bytecode is outside this
1294
+ // importer's scope by charter rather than data it failed to read. It reports the sprite it names
1295
+ // rather than a frame, because an init action runs before that sprite's first frame.
1296
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.frame-script-declined', 'readSwfTimeline', { capability: 'swf.script.do-init-action', characterId: spriteId });
1297
+ }
1298
+ else {
1159
1299
  state.pendingInitActions.push({ characterId: spriteId, script });
1300
+ }
1160
1301
  }
1161
1302
  else if (code === TAG_DO_ACTION) {
1162
1303
  // A DoAction belongs to the frame being assembled — the one the next ShowFrame closes.
1163
1304
  const script = readSwfFrameActions(new SwfReader(body.source, body.pos, body.end));
1164
1305
  if (script !== null)
1165
1306
  actions.set(frames.length + 1, script);
1307
+ // A block is recognized only when EVERY action in it is a playback command, because honouring the
1308
+ // legible half would misrepresent what the frame does. Skip rather than Drop: the bytecode is
1309
+ // outside this importer's scope by charter, not data it failed to read.
1310
+ else {
1311
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.frame-script-declined', 'readSwfTimeline', {
1312
+ capability: 'swf.script.do-action',
1313
+ frame: frames.length + 1,
1314
+ });
1315
+ }
1166
1316
  }
1167
1317
  else if (code === TAG_FRAME_LABEL) {
1168
1318
  addSwfTimelineLabel(labels, frames.length + 1, body.readString());
1169
1319
  }
1170
1320
  else if (code === TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA) {
1171
- readSwfSceneAndFrameLabelData(body, labels);
1321
+ readSwfSceneAndFrameLabelData(body, labels, state.diagnostics);
1172
1322
  }
1173
1323
  else if (code === TAG_DEFINE_SCALING_GRID) {
1174
1324
  readSwfScalingGrid(body, state);
@@ -1177,7 +1327,7 @@ function readSwfTimeline(reader, state) {
1177
1327
  readLegacyPlaceObject(body, placements);
1178
1328
  }
1179
1329
  else if (code === TAG_PLACE_OBJECT_2) {
1180
- readPlaceObject(body, placements, false);
1330
+ readPlaceObject(body, placements, false, state.diagnostics);
1181
1331
  }
1182
1332
  else if (code === TAG_REMOVE_OBJECT) {
1183
1333
  readLegacyRemoveObject(body, placements);
@@ -1238,7 +1388,7 @@ function readSwfTimeline(reader, state) {
1238
1388
  state.sprites.set(spriteId, spriteTimeline);
1239
1389
  }
1240
1390
  else if (code === TAG_PLACE_OBJECT_3 || code === TAG_PLACE_OBJECT_4) {
1241
- readPlaceObject(body, placements, true);
1391
+ readPlaceObject(body, placements, true, state.diagnostics);
1242
1392
  }
1243
1393
  else if (code === TAG_START_SOUND) {
1244
1394
  readSwfStartSound(body, state, cues, frames.length + 1);
@@ -1263,6 +1413,15 @@ function readSwfTimeline(reader, state) {
1263
1413
  if (body.valid && body.pos < body.end)
1264
1414
  streamChunks.push(body.source.subarray(body.pos, body.end));
1265
1415
  }
1416
+ else if (streamFormat >= 0) {
1417
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.stream-sound-format', 'readSwfTimeline', {
1418
+ capability: 'swf.axis.sound-format-non-mp3',
1419
+ format: streamFormat,
1420
+ });
1421
+ }
1422
+ }
1423
+ else {
1424
+ reportSwfDeclinedTag(state.diagnostics, code);
1266
1425
  }
1267
1426
  if (!body.valid)
1268
1427
  return null;
@@ -1273,15 +1432,46 @@ function readSwfTimeline(reader, state) {
1273
1432
  if (frames.length === 0)
1274
1433
  frames.push(placements);
1275
1434
  appendSwfStreamSoundCue(state, cues, streamChunks, streamStartFrame);
1435
+ // A label or cue authored after the last ShowFrame names a frame the timeline never reaches, so it is
1436
+ // dropped rather than bound to a frame that does not exist. Found by auditing the claim rather than the
1437
+ // wire: `swf.scene-names` covered only the scene table, while THIS is the other way that tag's data is
1438
+ // lost, and silence about it was being reported as trustworthy.
1439
+ const reachableCues = cues.filter((cue) => cue.frame <= frames.length);
1440
+ const reachableLabels = labels.filter((label) => label.frame <= frames.length);
1441
+ if (reachableLabels.length !== labels.length) {
1442
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.label-past-last-frame', 'readSwfTimeline', {
1443
+ capability: 'swf.timeline.frame-label',
1444
+ dropped: labels.length - reachableLabels.length,
1445
+ frames: frames.length,
1446
+ });
1447
+ }
1448
+ if (reachableCues.length !== cues.length) {
1449
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.cue-past-last-frame', 'readSwfTimeline', {
1450
+ dropped: cues.length - reachableCues.length,
1451
+ frames: frames.length,
1452
+ });
1453
+ }
1276
1454
  return {
1277
1455
  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),
1456
+ cues: reachableCues,
1280
1457
  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),
1458
+ labels: reachableLabels.sort(compareSwfTimelineLabelFrame),
1283
1459
  };
1284
1460
  }
1461
+ // Reports the tags this importer deliberately reads past that cost a real capability, and stays silent on
1462
+ // the ones that cost nothing. The line matters: a document is not worse off for having skipped
1463
+ // `FileAttributes` or a font's hinting table, so reporting those would bury the entries that do mean
1464
+ // something under noise a caller has to filter. Every code below is a decision recorded in
1465
+ // `agents/packages/swf/tag-coverage.md`, not an unrecognized tag.
1466
+ function reportSwfDeclinedTag(diagnostics, code) {
1467
+ if (diagnostics === undefined)
1468
+ return;
1469
+ const kind = SWF_DECLINED_TAG_KINDS.get(code);
1470
+ if (kind === undefined)
1471
+ return;
1472
+ const capability = SWF_DECLINED_TAG_CAPABILITIES.get(code);
1473
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, kind, 'readSwfTimeline', capability === undefined ? { tag: code } : { capability, tag: code });
1474
+ }
1285
1475
  // The stage colour, as an RGB record. SWF gives it no alpha, and a stage is opaque, so it packs to fully
1286
1476
  // opaque RGBA. Last declaration wins, matching how a player applies the most recent one it has read.
1287
1477
  // A button is a display character whose content is a small display list per interaction state. A document
@@ -1312,6 +1502,14 @@ function readSwfButtonDefinition(body, state, version) {
1312
1502
  const colorTransform = version === 2 ? readSwfColorTransform(reader) : null;
1313
1503
  if (!reader.valid)
1314
1504
  return;
1505
+ if ((flags & BUTTON_STATE_UP) === 0 && characterId !== 0) {
1506
+ // A document is a still scene, so only the up state is held. The other states are a real capability
1507
+ // gap rather than data this decoder could not read, which is why they Skip rather than Drop.
1508
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Skip, 'swf.button-interaction-state', 'readSwfButtonDefinition', {
1509
+ characterId,
1510
+ flags,
1511
+ });
1512
+ }
1315
1513
  if ((flags & BUTTON_STATE_UP) !== 0 && characterId !== 0) {
1316
1514
  placements.set(depth, {
1317
1515
  advancedBlendMode: null,
@@ -1348,9 +1546,28 @@ function readSwfFontDefinition(body, state, code) {
1348
1546
  const version = code === TAG_DEFINE_FONT ? 1 : code === TAG_DEFINE_FONT_2 ? 2 : 3;
1349
1547
  const reader = new SwfReader(body.source, body.pos, body.end);
1350
1548
  const fontId = reader.source[body.pos] + reader.source[body.pos + 1] * 0x100;
1351
- const source = readSwfFontGlyphOutlineSource(reader, version);
1352
- if (source === null || fontId === 0)
1549
+ const source = readSwfFontGlyphOutlineSource(reader, version, state.diagnostics, fontId);
1550
+ // A glyph table this decoder cannot read at all costs the WHOLE font, not one glyph, so it is a
1551
+ // separate loss from `swf.font-glyph-outline` and must report separately: without this, a font that
1552
+ // vanished entirely and a font that imported cleanly both produce no crumb.
1553
+ if (source === null) {
1554
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.font-glyph-table', 'readSwfFontDefinition', {
1555
+ capability: version === 1 ? 'swf.font.define-font' : `swf.font.define-font-${version}`,
1556
+ characterId: fontId,
1557
+ });
1353
1558
  return;
1559
+ }
1560
+ if (fontId === 0)
1561
+ return;
1562
+ if (state.fontOutlineSources.has(fontId)) {
1563
+ // Every other definition kind rejects the document on a repeated character id; fonts do not, so the
1564
+ // second table silently replaces the first. The document imports, the font exists, and it is the
1565
+ // wrong font — no existence check and no count can see that, which is why it is reported here.
1566
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.font-character-id-reused', 'readSwfFontDefinition', {
1567
+ capability: version === 1 ? 'swf.font.define-font' : `swf.font.define-font-${version}`,
1568
+ characterId: fontId,
1569
+ });
1570
+ }
1354
1571
  state.fontOutlineSources.set(fontId, source);
1355
1572
  const fontName = readSwfFontName(body, version);
1356
1573
  if (fontName !== '')
@@ -1438,12 +1655,21 @@ function readSwfScalingGrid(body, state) {
1438
1655
  return;
1439
1656
  state.scalingGrids.set(characterId, splitter);
1440
1657
  }
1441
- function readSwfSceneAndFrameLabelData(body, labels) {
1658
+ function readSwfSceneAndFrameLabelData(body, labels, diagnostics) {
1442
1659
  const sceneCount = body.readEncodedUint32();
1443
1660
  for (let i = 0; i < sceneCount && body.valid; i++) {
1444
1661
  body.readEncodedUint32();
1445
1662
  body.readString();
1446
1663
  }
1664
+ // Scene names are read past to reach the label table behind them. Skip rather than Drop: Flight has
1665
+ // frame labels but no subject for a named frame range, so this is a capability gap rather than data
1666
+ // this decoder failed to read.
1667
+ if (sceneCount > 0) {
1668
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'swf.scene-names', 'readSwfSceneAndFrameLabelData', {
1669
+ capability: 'swf.timeline.define-scene-and-frame-label-data',
1670
+ sceneCount,
1671
+ });
1672
+ }
1447
1673
  const labelCount = body.readEncodedUint32();
1448
1674
  for (let i = 0; i < labelCount && body.valid; i++) {
1449
1675
  const frame = body.readEncodedUint32();
@@ -1494,8 +1720,12 @@ function readSwfBoundedDefinition(body, state, code) {
1494
1720
  const reader = new SwfReader(body.source, body.pos, body.end);
1495
1721
  const bounds = state.characterBounds.get(characterId);
1496
1722
  const factory = readSwfEditTextFactory(reader, bounds?.width ?? 0, bounds?.height ?? 0);
1497
- if (factory !== null)
1723
+ if (factory === null) {
1724
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.edit-text-unparseable', 'readSwfBoundedDefinition', { capability: 'swf.text.define-edit-text', characterId });
1725
+ }
1726
+ else {
1498
1727
  state.editTexts.set(characterId, factory);
1728
+ }
1499
1729
  }
1500
1730
  if (code === TAG_DEFINE_TEXT || code === TAG_DEFINE_TEXT_2) {
1501
1731
  const reader = new SwfReader(body.source, body.pos, body.end);
@@ -1525,9 +1755,18 @@ function readSwfShapeBody(body, state, characterId, version) {
1525
1755
  return;
1526
1756
  // The fill's character is not this shape's, hence the distinct name: a bitmap fill names whatever
1527
1757
  // character carries its pixels, which may be defined later in the tag stream.
1528
- const shape = createSwfShape(reader, version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed));
1529
- if (shape !== null)
1530
- state.shapes.set(characterId, shape);
1758
+ const shape = createSwfShape(reader, version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed), state.diagnostics);
1759
+ if (shape === null) {
1760
+ // Recover rather than Drop: the character survives as the bounded placeholder it was before any
1761
+ // geometry existed, so the document still places and sizes it — only the drawing is missing.
1762
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Recover, 'swf.shape-body-unreadable', 'readSwfShapeDefinition', {
1763
+ capability: version === 1 ? 'swf.shape.define-shape' : `swf.shape.define-shape-${version}`,
1764
+ characterId,
1765
+ version,
1766
+ });
1767
+ return;
1768
+ }
1769
+ state.shapes.set(characterId, shape);
1531
1770
  }
1532
1771
  // Decodes a morph definition's geometry and paint on a reader of its own, so a body this decoder cannot
1533
1772
  // read costs only that body. The definition keeps its authored bounds and contributes no drawing, which
@@ -1539,9 +1778,17 @@ function readSwfMorphShapeBody(body, state, characterId, version) {
1539
1778
  const source = body.source;
1540
1779
  const start = body.pos;
1541
1780
  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);
1781
+ // Only the parse-time validation call carries the sink. The stored closure runs again per placement,
1782
+ // potentially long after import returned, and a sink written to then would append to a collection
1783
+ // whose consumer has already read it.
1784
+ const decode = (diagnostics) => createSwfMorphShape(new SwfReader(source, start, end), version, (fillCharacterId, repeat, smoothed) => acquireSwfImageTexture(state, fillCharacterId, repeat, smoothed), diagnostics);
1785
+ if (decode(state.diagnostics) === null) {
1786
+ // The character is simply absent from the document afterwards, so without this the loss has no
1787
+ // signal at all: a placement of it resolves to nothing and the import still reports success.
1788
+ 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 });
1789
+ return;
1790
+ }
1791
+ state.morphShapes.set(characterId, decode);
1545
1792
  }
1546
1793
  function resolveSwfMorphShapeVersion(code) {
1547
1794
  if (code === TAG_DEFINE_MORPH_SHAPE)
@@ -1569,8 +1816,13 @@ function readSwfLegacyImageDefinition(body, state) {
1569
1816
  if (!body.valid || characterId === 0 || state.definedCharacters.has(characterId))
1570
1817
  return;
1571
1818
  const tables = state.jpegTables;
1572
- if (tables === null)
1819
+ if (tables === null) {
1820
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-tables-missing', 'readSwfLegacyImageDefinition', {
1821
+ capability: 'swf.bitmap.define-bits-jpeg-tables',
1822
+ characterId,
1823
+ });
1573
1824
  return;
1825
+ }
1574
1826
  const tablesEnd = tables.length >= 2 && tables[tables.length - 2] === 0xff && tables[tables.length - 1] === JPEG_END_OF_IMAGE
1575
1827
  ? tables.length - 2
1576
1828
  : tables.length;
@@ -1579,8 +1831,13 @@ function readSwfLegacyImageDefinition(body, state) {
1579
1831
  spliced.set(tables.subarray(0, tablesEnd));
1580
1832
  spliced.set(body.source.subarray(imageStart, body.end), tablesEnd);
1581
1833
  const image = readSwfEmbeddedImage(spliced, 0, spliced.length);
1582
- if (image === null)
1834
+ if (image === null) {
1835
+ reportImportDiagnostic(state.diagnostics, ImportDiagnosticSeverity.Drop, 'swf.jpeg-tables-unsplittable', 'readSwfLegacyImageDefinition', {
1836
+ capability: 'swf.bitmap.define-bits-jpeg-tables',
1837
+ characterId,
1838
+ });
1583
1839
  return;
1840
+ }
1584
1841
  state.definedCharacters.add(characterId);
1585
1842
  state.characterBounds.set(characterId, image.bounds);
1586
1843
  state.images.set(characterId, { bytes: spliced, mimeType: image.mimeType });
@@ -1590,13 +1847,16 @@ function readSwfLegacyImageDefinition(body, state) {
1590
1847
  // costs far more than the picture is worth.
1591
1848
  function readSwfEmbeddedImageDefinition(body, state, code) {
1592
1849
  const characterId = body.readUint16();
1850
+ let deblockingParameterRaw = null;
1593
1851
  let imageStart = body.pos;
1594
1852
  let imageEnd = body.end;
1853
+ let hasAlphaPayload = false;
1595
1854
  if (code === TAG_DEFINE_BITS_JPEG_3 || code === TAG_DEFINE_BITS_JPEG_4) {
1855
+ hasAlphaPayload = true;
1596
1856
  const alphaDataOffset = body.readUint32();
1597
1857
  const alphaOffsetBase = body.pos;
1598
1858
  if (code === TAG_DEFINE_BITS_JPEG_4)
1599
- body.readUint16();
1859
+ deblockingParameterRaw = body.readUint16();
1600
1860
  imageStart = body.pos;
1601
1861
  imageEnd = alphaOffsetBase + alphaDataOffset;
1602
1862
  }
@@ -1616,6 +1876,28 @@ function readSwfEmbeddedImageDefinition(body, state, code) {
1616
1876
  bytes: stripSwfJpegStreamBoundary(body.source, imageStart, imageEnd, image.mimeType),
1617
1877
  mimeType: image.mimeType,
1618
1878
  });
1879
+ if (hasAlphaPayload) {
1880
+ const compressedAlphaBytes = body.source.subarray(imageEnd, body.end);
1881
+ state.jpegAlphaPayloads.set(characterId, {
1882
+ characterId,
1883
+ compressedAlphaBytes,
1884
+ deblockingParameterRaw,
1885
+ height: image.bounds.height,
1886
+ width: image.bounds.width,
1887
+ });
1888
+ // The full report retains this stream, but the document's colour reference still resolves without
1889
+ // applying it. Keep the existing fidelity diagnostic until the separately-gated composition stage.
1890
+ if (compressedAlphaBytes.length > 0) {
1891
+ reportImportDiagnostic(state.diagnostics,
1892
+ // Skip: the alpha stream is recognized and deliberately not applied until the composition stage
1893
+ // exists. A capability gap on a well-formed file, with no substitute written.
1894
+ ImportDiagnosticSeverity.Skip, 'swf.jpeg-alpha-stream', 'readSwfEmbeddedImageDefinition', {
1895
+ capability: code === TAG_DEFINE_BITS_JPEG_3 ? 'swf.bitmap.define-bits-jpeg-3' : 'swf.bitmap.define-bits-jpeg-4',
1896
+ characterId,
1897
+ discardedBytes: compressedAlphaBytes.length,
1898
+ });
1899
+ }
1900
+ }
1619
1901
  return;
1620
1902
  }
1621
1903
  // Identifies an embedded payload by its magic bytes and reads the dimensions out of its header, without
@@ -2030,8 +2312,6 @@ const S_SIGNATURE = 0x53;
2030
2312
  const MORPH_RATIO_ONE = 0xffff;
2031
2313
  const SWF_INSTANCE_KEY_SCALE = 0x10000;
2032
2314
  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
2315
  const SWF_MIME_TYPE = 'application/x-shockwave-flash';
2036
2316
  const SWF_PREFIX_LENGTH = 8;
2037
2317
  const SOUND_ENVELOPE_LEVEL_ONE = 32768;
@@ -2065,6 +2345,22 @@ const TAG_START_SOUND_2 = 89;
2065
2345
  const TAG_SOUND_STREAM_BLOCK = 19;
2066
2346
  const TAG_SOUND_STREAM_HEAD = 18;
2067
2347
  const TAG_SOUND_STREAM_HEAD_2 = 45;
2348
+ // Only tags whose absence loses something a caller could want. Metadata tags — `FileAttributes`,
2349
+ // `Metadata`, `ProductInfo`, `ScriptLimits`, `DebugID`, `EnableDebugger2`, `EnableTelemetry`, `Protect`,
2350
+ // `SetTabIndex`, `DefineButtonCxform`, and the font hinting/naming tables — are deliberately absent: they
2351
+ // carry no scene content, so skipping one is not a loss to report.
2352
+ // The declared capability a declined tag costs, where one exists. Deliberately partial: `DefineFont4`,
2353
+ // `DefineBinaryData`, `ImportAssets` and `DefineButtonSound` name no declared capability, and inventing
2354
+ // one so every crumb could carry a join key would put entries in the denominator that nothing measures.
2355
+ const SWF_DECLINED_TAG_CAPABILITIES = new Map([[61, 'swf.video.video-frame']]);
2356
+ const SWF_DECLINED_TAG_KINDS = new Map([
2357
+ [17, 'swf.define-button-sound'],
2358
+ [57, 'swf.import-assets'],
2359
+ [61, 'swf.video-frame-payload'],
2360
+ [71, 'swf.import-assets'],
2361
+ [87, 'swf.define-binary-data'],
2362
+ [91, 'swf.define-font-4'],
2363
+ ]);
2068
2364
  const TAG_DEFINE_SCALING_GRID = 78;
2069
2365
  const TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA = 86;
2070
2366
  const TAG_DEFINE_BITS_JPEG_2 = 21;