@giveitsmaller/sdk 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Typed per-op option interfaces for the ergonomic verbs (card Dhje3Faq).
3
+ * These replace the untyped `Record<string, unknown>` bags so the IDE can offer
4
+ * key completion and `tsc` rejects typos. The KEY SET of each interface is pinned
5
+ * to the contract two ways: (1) the source-level `Equal<...>` assertions below tie
6
+ * each interface to its `*_OPTION_KEYS` tuple at `tsc` time; (2) the wire-key
7
+ * conformance guard ties each tuple (∪ positional-owned) to the generated
8
+ * `OperationMetadata` at test time. Value types are best-effort (per-value/enum
9
+ * sync is out of scope — keys are the contract anchor).
10
+ *
11
+ * Keys a verb owns via a positional argument are EXCLUDED from its interface
12
+ * (`output_format` on convert, `text` on textWatermark) — they are set by the
13
+ * first argument and rejected if supplied in the bag (see `option_validation.ts`).
14
+ *
15
+ * Mirrored by the PHP array-shape docblocks — keep in lockstep.
16
+ */
17
+ const CONVERT_OPTION_KEYS = [
18
+ 'quality', 'background', 'crf', 'trim_start', 'trim_end', 'fps', 'width',
19
+ 'max_colors', 'loop', 'dither', 'bitrate', 'pages', 'dpi',
20
+ ];
21
+ const THUMBNAIL_OPTION_KEYS = [
22
+ 'width', 'height', 'fit', 'format', 'quality', 'timestamp', 'source', 'page',
23
+ ];
24
+ const TEXT_WATERMARK_OPTION_KEYS = [
25
+ 'font_size', 'color', 'font_family', 'rotation', 'watermark_mode',
26
+ 'tile_spacing', 'anchor', 'margin_x', 'margin_y', 'opacity',
27
+ ];
28
+ const WATERMARK_OPTION_KEYS = [
29
+ 'anchor', 'margin_x', 'margin_y', 'opacity', 'overlay_width',
30
+ ];
31
+ const OUTPUT_OPTION_KEYS = [
32
+ 'quality', 'width', 'height', 'fit', 'background', 'progressive',
33
+ 'optimization_level', 'avif_speed', 'metadata', 'lossless', 'lossy',
34
+ ];
35
+ const _convertKeysMatch = true;
36
+ const _thumbnailKeysMatch = true;
37
+ const _textWatermarkKeysMatch = true;
38
+ const _watermarkKeysMatch = true;
39
+ const _outputKeysMatch = true;
40
+ // Reference the assertions so `noUnusedLocals` doesn't strip them.
41
+ void _convertKeysMatch;
42
+ void _thumbnailKeysMatch;
43
+ void _textWatermarkKeysMatch;
44
+ void _watermarkKeysMatch;
45
+ void _outputKeysMatch;
46
+ /**
47
+ * The user-supplyable option keys per verb (excludes positional-owned keys).
48
+ * Exported for the wire-key conformance guard, which asserts each tuple ∪ its
49
+ * positional-owned keys equals the contract `operationOptionKeys(metadata)`.
50
+ */
51
+ export const VERB_OPTION_KEYS = {
52
+ convert: CONVERT_OPTION_KEYS,
53
+ thumbnail: THUMBNAIL_OPTION_KEYS,
54
+ textWatermark: TEXT_WATERMARK_OPTION_KEYS,
55
+ watermark: WATERMARK_OPTION_KEYS,
56
+ output: OUTPUT_OPTION_KEYS,
57
+ };
@@ -0,0 +1,58 @@
1
+ import { type OperationMetadata } from '@giveitsmaller/contracts/operations';
2
+ /**
3
+ * Eager, synchronous, PRE-UPLOAD option-key validation for the ergonomic verbs
4
+ * (card Dhje3Faq). The file-first builders accept verb options as untyped bags;
5
+ * a user typo (`{ quaity: 80 }`) would otherwise flow to the server and 422.
6
+ * This module rejects unknown keys at the verb call — before any upload —
7
+ * mirroring the existing `compress()` optimize-check and the watermark eager
8
+ * gate. The allowed key set is read from the generated `OperationMetadata`
9
+ * sidecars (the same contract-anchored source the wire-key conformance guard
10
+ * uses), so it can never silently drift from the contract.
11
+ *
12
+ * SCOPE: `convert` / `thumbnail` / `textWatermark` / `watermark` only. `compress`
13
+ * is deliberately EXCLUDED — its bag legitimately carries SDK-only keys
14
+ * (`optimize`, `presetOverrides`) and camelCase resolver aliases (`targetSize`,
15
+ * `outputFormat`) that are not `operationOptionKeys(compressMetadata)`; it has its
16
+ * own `unknown_field` validation through the preset resolver.
17
+ *
18
+ * Mirrored by the PHP `OptionValidation` helper — keep the two in lockstep.
19
+ */
20
+ /**
21
+ * OPERATION-LEVEL contract option keys (the keys valid in `OperationDef.options`):
22
+ * the union of every mime group's `options` plus `direct_options` for
23
+ * media-agnostic ops. Deliberately EXCLUDES `per_input_options` (valid only on a
24
+ * merge input). Promoted from the wire-key conformance guard for runtime reuse.
25
+ */
26
+ export declare function operationOptionKeys(metadata: OperationMetadata): ReadonlySet<string>;
27
+ /** The ergonomic verbs whose option bags this module key-validates. */
28
+ export type ValidatedVerb = 'convert' | 'thumbnail' | 'textWatermark' | 'watermark' | 'output';
29
+ /** Accessor for the conformance guard (pins these sets to the contract metadata). */
30
+ export declare function allowedKeysFor(verb: ValidatedVerb): ReadonlySet<string>;
31
+ /**
32
+ * Validate a USER-supplied options bag for an ergonomic verb. Throws
33
+ * {@link GislConfigError} (reason `unknown_field`) synchronously, BEFORE any
34
+ * upload or wire-key injection. Call this at the TOP of every verb body, before
35
+ * the `format`-drop / `output_format` / `text` injection.
36
+ *
37
+ * @throws {GislConfigError} reason `unknown_field` when the bag carries a key the
38
+ * verb owns via a positional argument, or a key absent from the op's contract
39
+ * option set.
40
+ */
41
+ export declare function validateVerbOptions(verb: ValidatedVerb, options: object | null | undefined): void;
42
+ /**
43
+ * Assert thumbnail `width` AND `height` are both present and non-nullish (the
44
+ * contract marks both `required` for image/video/document). The typed signature
45
+ * already enforces this at compile time; this RUNTIME guard catches JS callers and
46
+ * an explicit `undefined`/`null` BEFORE upload. Rejecting `null` (not just
47
+ * `undefined`) keeps TS in lockstep with the PHP `assertThumbnailDimensions`, which
48
+ * must reject `null` because PHP drops null values pre-lower — so a `null` dimension
49
+ * is a pre-upload error in BOTH languages, never a wire `null` that 422s. Mirrored
50
+ * in PHP.
51
+ *
52
+ * @throws {GislConfigError} reason `missing_required_field` naming the absent
53
+ * dimension(s) in `conflictingFields`.
54
+ */
55
+ export declare function assertThumbnailDimensions(options: {
56
+ width?: unknown;
57
+ height?: unknown;
58
+ } | null | undefined): void;
@@ -0,0 +1,143 @@
1
+ import { convertMetadata, thumbnailMetadata, textWatermarkMetadata, imageWatermarkMetadata, videoWatermarkMetadata, } from '@giveitsmaller/contracts/operations';
2
+ import { GislConfigError } from '../errors.js';
3
+ import { VERB_OPTION_KEYS } from './option_types.js';
4
+ /**
5
+ * Eager, synchronous, PRE-UPLOAD option-key validation for the ergonomic verbs
6
+ * (card Dhje3Faq). The file-first builders accept verb options as untyped bags;
7
+ * a user typo (`{ quaity: 80 }`) would otherwise flow to the server and 422.
8
+ * This module rejects unknown keys at the verb call — before any upload —
9
+ * mirroring the existing `compress()` optimize-check and the watermark eager
10
+ * gate. The allowed key set is read from the generated `OperationMetadata`
11
+ * sidecars (the same contract-anchored source the wire-key conformance guard
12
+ * uses), so it can never silently drift from the contract.
13
+ *
14
+ * SCOPE: `convert` / `thumbnail` / `textWatermark` / `watermark` only. `compress`
15
+ * is deliberately EXCLUDED — its bag legitimately carries SDK-only keys
16
+ * (`optimize`, `presetOverrides`) and camelCase resolver aliases (`targetSize`,
17
+ * `outputFormat`) that are not `operationOptionKeys(compressMetadata)`; it has its
18
+ * own `unknown_field` validation through the preset resolver.
19
+ *
20
+ * Mirrored by the PHP `OptionValidation` helper — keep the two in lockstep.
21
+ */
22
+ /**
23
+ * OPERATION-LEVEL contract option keys (the keys valid in `OperationDef.options`):
24
+ * the union of every mime group's `options` plus `direct_options` for
25
+ * media-agnostic ops. Deliberately EXCLUDES `per_input_options` (valid only on a
26
+ * merge input). Promoted from the wire-key conformance guard for runtime reuse.
27
+ */
28
+ export function operationOptionKeys(metadata) {
29
+ const keys = new Set();
30
+ for (const group of Object.values(metadata.mime_groups)) {
31
+ for (const k of Object.keys(group.options))
32
+ keys.add(k);
33
+ }
34
+ for (const k of Object.keys(metadata.direct_options ?? {}))
35
+ keys.add(k);
36
+ return keys;
37
+ }
38
+ function union(...sets) {
39
+ const out = new Set();
40
+ for (const set of sets)
41
+ for (const k of set)
42
+ out.add(k);
43
+ return out;
44
+ }
45
+ /**
46
+ * The contract option-key set per validated verb (the GENERIC allowed set).
47
+ * `watermark` is the UNION of `image_watermark` + `video_watermark` because the
48
+ * base media may be undetectable at the `.watermark()` call; the existing
49
+ * media-routing gate still rejects the wrong route by media.
50
+ */
51
+ const ALLOWED_KEYS = {
52
+ convert: operationOptionKeys(convertMetadata),
53
+ thumbnail: operationOptionKeys(thumbnailMetadata),
54
+ textWatermark: operationOptionKeys(textWatermarkMetadata),
55
+ watermark: union(operationOptionKeys(imageWatermarkMetadata), operationOptionKeys(videoWatermarkMetadata)),
56
+ // `output` is the image Output facade — its allowed keys are the UNION of every
57
+ // image route's honored+planned options (the image-output-routes projection),
58
+ // INCLUDING `output_format` (in every cell's honored set) which — like `convert`
59
+ // — is in the allowed set but rejected first by the positional-owned guard. This
60
+ // is the COARSE static gate (reject keys no image route ever honors, e.g. a video
61
+ // `crf`); the precise per-route honored/planned narrowing happens in the
62
+ // `output()` lowering (`resolveOutputRoute`). Pinned to the projection union by
63
+ // the output-route conformance test.
64
+ output: new Set([...VERB_OPTION_KEYS.output, 'output_format']),
65
+ };
66
+ /**
67
+ * Keys a verb OWNS via a positional argument: a user must not also supply them
68
+ * in the options bag (they would be silently overridden by the positional). The
69
+ * guard runs BEFORE the generic check so these get a specific, actionable
70
+ * message rather than the generic "unknown option" one. `format` is an SDK alias
71
+ * for the positional (not a contract key) and is owned too.
72
+ */
73
+ const POSITIONAL_OWNED = {
74
+ convert: ['output_format', 'format'],
75
+ textWatermark: ['text'],
76
+ // `output(format, …)` sets the target format via its first argument; the wire
77
+ // key `output_format` and the SDK alias `format` must not be supplied in the bag.
78
+ output: ['output_format', 'format'],
79
+ };
80
+ /** Accessor for the conformance guard (pins these sets to the contract metadata). */
81
+ export function allowedKeysFor(verb) {
82
+ return ALLOWED_KEYS[verb];
83
+ }
84
+ /**
85
+ * Validate a USER-supplied options bag for an ergonomic verb. Throws
86
+ * {@link GislConfigError} (reason `unknown_field`) synchronously, BEFORE any
87
+ * upload or wire-key injection. Call this at the TOP of every verb body, before
88
+ * the `format`-drop / `output_format` / `text` injection.
89
+ *
90
+ * @throws {GislConfigError} reason `unknown_field` when the bag carries a key the
91
+ * verb owns via a positional argument, or a key absent from the op's contract
92
+ * option set.
93
+ */
94
+ export function validateVerbOptions(verb, options) {
95
+ // The typed signatures require an options object, but an untyped JS caller can
96
+ // still omit it (e.g. `thumbnail()` — which dropped its `= {}` default when
97
+ // width/height became required). A nullish bag has no keys to reject; the
98
+ // separate `assertThumbnailDimensions` then reports the missing dimensions as a
99
+ // clean GislConfigError rather than a raw TypeError.
100
+ if (options === null || options === undefined)
101
+ return;
102
+ const owned = POSITIONAL_OWNED[verb];
103
+ if (owned !== undefined) {
104
+ for (const key of owned) {
105
+ if (Object.prototype.hasOwnProperty.call(options, key)) {
106
+ const arg = verb === 'textWatermark' ? 'text' : 'output format';
107
+ throw new GislConfigError(`${verb}() sets the ${arg} via its first argument; remove '${key}' from the options bag.`, { reason: 'unknown_field', conflictingFields: [key] });
108
+ }
109
+ }
110
+ }
111
+ const allowed = ALLOWED_KEYS[verb];
112
+ for (const key of Object.keys(options)) {
113
+ if (!allowed.has(key)) {
114
+ throw new GislConfigError(`${verb}: unknown option '${key}'. Valid options: ${[...allowed].sort().join(', ')}.`, { reason: 'unknown_field', conflictingFields: [key] });
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Assert thumbnail `width` AND `height` are both present and non-nullish (the
120
+ * contract marks both `required` for image/video/document). The typed signature
121
+ * already enforces this at compile time; this RUNTIME guard catches JS callers and
122
+ * an explicit `undefined`/`null` BEFORE upload. Rejecting `null` (not just
123
+ * `undefined`) keeps TS in lockstep with the PHP `assertThumbnailDimensions`, which
124
+ * must reject `null` because PHP drops null values pre-lower — so a `null` dimension
125
+ * is a pre-upload error in BOTH languages, never a wire `null` that 422s. Mirrored
126
+ * in PHP.
127
+ *
128
+ * @throws {GislConfigError} reason `missing_required_field` naming the absent
129
+ * dimension(s) in `conflictingFields`.
130
+ */
131
+ export function assertThumbnailDimensions(options) {
132
+ // Null-safe so an untyped JS `thumbnail()` (no args, no default) reports both
133
+ // dimensions missing as a clean GislConfigError instead of a raw TypeError.
134
+ const o = options ?? {};
135
+ const missing = [];
136
+ if (o.width === undefined || o.width === null)
137
+ missing.push('width');
138
+ if (o.height === undefined || o.height === null)
139
+ missing.push('height');
140
+ if (missing.length > 0) {
141
+ throw new GislConfigError(`thumbnail requires both width and height (the contract marks both required); missing: ${missing.join(', ')}.`, { reason: 'missing_required_field', conflictingFields: missing });
142
+ }
143
+ }
@@ -2,13 +2,14 @@ import type { ResolvedOptions } from '../builder.js';
2
2
  import type { OptimizeFor } from '../generated/sdk_spec/enums.js';
3
3
  import { type PresetDefaults, type PresetMedia, type PresetOp } from './presets/index.js';
4
4
  /**
5
- * Bumped on any change to a `*PresetOptions.shippedDefaultsFor(...)` cell value.
6
- * Must track the contracts `sdk-spec/version.yaml` `presetVersion` (mirrored in
7
- * the generated `sdk_spec/version.ts`). 1.0 1.2 on the contracts v2.71.0
8
- * (video_compress `audioBitrate` dropped) + v2.73.0 (image Size/Balanced
9
- * `outputFormat` Smallest/Auto Original VcPeRWdD facade self-422 guard) cuts.
5
+ * The preset matrix version emitted on every resolve. Re-exported from the
6
+ * GENERATED `sdk_spec/version.ts` (source of truth: contracts
7
+ * `sdk-spec/version.yaml` `presetVersion`) so it can NEVER drift from the
8
+ * generated preset cells a regen that bumps the cells bumps this by
9
+ * construction. Previously a hand-typed literal that the v2.73.0 regen had to
10
+ * bump manually (yREs0srv).
10
11
  */
11
- export declare const PRESET_VERSION = "1.2";
12
+ export declare const PRESET_VERSION: "1.4";
12
13
  /**
13
14
  * Inputs to {@link resolveCompressOptions}. `media` selects which leaf
14
15
  * DTO drives sdkDefault + clientDefault lookups + invalid-combo
@@ -21,8 +21,8 @@
21
21
  // `quality: 100`, `codec: 'h264'`, …).
22
22
  //
23
23
  // Validation runs AFTER merge so post-merge-only invariants
24
- // (targetSize + codec, Lossless + quality) catch combinations where
25
- // e.g. the explicit knob and a layered default disagree. Resolver
24
+ // (e.g. targetSize + codec on video) catch combinations where
25
+ // the explicit knob and a layered default disagree. Resolver
26
26
  // throws `GislConfigError` BEFORE any network round-trip, with a
27
27
  // `resolvedSnapshot` showing the would-have-been-sent payload.
28
28
  //
@@ -36,33 +36,33 @@
36
36
  // to the wire; conversely if `crf` is explicit, `encoding_mode='crf'`.
37
37
  import { sha256Hex } from '../sha256.js';
38
38
  import { GislConfigError } from '../errors.js';
39
- import { ImageCompressPresetOptions, AudioCompressPresetOptions, VideoCompressPresetOptions, DocumentPdfCompressPresetOptions, DocumentOfficeCompressPresetOptions, DocumentOdfCompressPresetOptions, DocumentEpubCompressPresetOptions, } from './presets/index.js';
39
+ import { PRESET_VERSION as GENERATED_PRESET_VERSION } from '../generated/sdk_spec/version.js';
40
+ import { ImageCompressPresetOptions, AudioCompressPresetOptions, VideoCompressPresetOptions, DocumentPdfCompressPresetOptions, DocumentOfficeCompressPresetOptions, DocumentOdfCompressPresetOptions, DocumentEpubCompressPresetOptions, definedFieldsOf, } from './presets/index.js';
40
41
  /**
41
- * Bumped on any change to a `*PresetOptions.shippedDefaultsFor(...)` cell value.
42
- * Must track the contracts `sdk-spec/version.yaml` `presetVersion` (mirrored in
43
- * the generated `sdk_spec/version.ts`). 1.0 1.2 on the contracts v2.71.0
44
- * (video_compress `audioBitrate` dropped) + v2.73.0 (image Size/Balanced
45
- * `outputFormat` Smallest/Auto Original VcPeRWdD facade self-422 guard) cuts.
42
+ * The preset matrix version emitted on every resolve. Re-exported from the
43
+ * GENERATED `sdk_spec/version.ts` (source of truth: contracts
44
+ * `sdk-spec/version.yaml` `presetVersion`) so it can NEVER drift from the
45
+ * generated preset cells a regen that bumps the cells bumps this by
46
+ * construction. Previously a hand-typed literal that the v2.73.0 regen had to
47
+ * bump manually (yREs0srv).
46
48
  */
47
- export const PRESET_VERSION = '1.2';
49
+ export const PRESET_VERSION = GENERATED_PRESET_VERSION;
48
50
  // ---------------------------------------------------------------------------
49
51
  // Wire-field alias map (declarative — NOT generic toSnakeCase).
50
52
  // ---------------------------------------------------------------------------
51
53
  //
52
54
  // Lifted from docs/plans/sdk-ergonomics/plan.md §11a. Maps camelCase
53
55
  // ergonomic-DTO field names to their snake_case wire counterparts.
54
- // Fields whose ergonomic name IS the wire name (`mode`, `quality`,
55
- // `codec`, …) are NOT in this map — `applyAlias` returns them
56
- // unchanged. A generic snake-case regex would mistranslate names like
57
- // `iccProfile` to `i_c_c_profile`; the declarative map is the only
58
- // safe path.
56
+ // Fields whose ergonomic name IS the wire name (`quality`, `codec`,
57
+ // …) are NOT in this map — `applyAlias` returns them unchanged. A
58
+ // generic snake-case regex would mistranslate acronym/camel names
59
+ // (e.g. an `outputFormat` `output_format` rename or an acronym like
60
+ // the former `iccProfile`); the declarative map is the only safe path.
59
61
  const WIRE_ALIASES = Object.freeze({
60
- iccProfile: 'icc_profile',
61
62
  outputFormat: 'output_format',
62
63
  sampleRate: 'sample_rate',
63
64
  audioCodec: 'audio_codec',
64
65
  audioBitrate: 'audio_bitrate',
65
- flattenForms: 'flatten_forms',
66
66
  imageQuality: 'image_quality',
67
67
  stripMacros: 'strip_macros',
68
68
  stripHiddenData: 'strip_hidden_data',
@@ -199,10 +199,10 @@ function presetDefaultsCellRecord(defaults, media, op, optimize) {
199
199
  if (op !== 'compress')
200
200
  return undefined;
201
201
  // Each overload returns `<Specific>PresetOptions | undefined`. We
202
- // erase the per-media type at runtime by spreading the instance
203
- // into a plain Record. `cellFor` returns `undefined` when no delta
204
- // was registered for the tuple — the resolver treats that the same
205
- // as "this layer did not participate."
202
+ // erase the per-media type at runtime by reducing the instance to a
203
+ // plain Record. `cellFor` returns `undefined` when no delta was
204
+ // registered for the tuple — the resolver treats that the same as
205
+ // "this layer did not participate."
206
206
  let cell;
207
207
  switch (media) {
208
208
  case 'image':
@@ -229,7 +229,16 @@ function presetDefaultsCellRecord(defaults, media, op, optimize) {
229
229
  }
230
230
  if (cell === undefined)
231
231
  return undefined;
232
- return { ...cell };
232
+ // Reduce to a SPARSE camelCase record, dropping undefined-valued keys.
233
+ // The leaf DTO's field DECLARATIONS define every field as an
234
+ // own-enumerable `undefined` property under `useDefineForClassFields`
235
+ // (ES2022) even when the ctor skipped the assignment — a naive spread
236
+ // would carry those `undefined`s into the presetConfigHash input,
237
+ // diverging from PHP's sparse `leafToRecord` (SVQcoR1K). `mergeLayer`
238
+ // already ignores `undefined`, so this only affects the hash path.
239
+ // Reuses the same helper `PresetDefaults.merge` uses for this exact
240
+ // `useDefineForClassFields` problem.
241
+ return definedFieldsOf(cell);
233
242
  }
234
243
  // ---------------------------------------------------------------------------
235
244
  // presetOverrides type-mismatch detection
@@ -241,10 +250,10 @@ function presetDefaultsCellRecord(defaults, media, op, optimize) {
241
250
  // for clearly-typed plain objects whose key set only intersects with a
242
251
  // non-matching media.
243
252
  const MEDIA_FIELDS = Object.freeze({
244
- image: new Set(['mode', 'quality', 'metadata', 'iccProfile', 'progressive', 'outputFormat']),
253
+ image: new Set(['quality', 'metadata', 'outputFormat']),
245
254
  audio: new Set(['bitrate', 'channels', 'sampleRate', 'normalize']),
246
255
  video: new Set(['codec', 'targetSize', 'crf', 'preset', 'width', 'height', 'fit', 'fps', 'faststart', 'audioCodec', 'audioBitrate']),
247
- document_pdf: new Set(['profile', 'colorspace', 'flattenForms']),
256
+ document_pdf: new Set(['profile', 'grayscale']),
248
257
  document_office: new Set(['imageQuality', 'stripMacros', 'stripHiddenData', 'stripUnusedFonts']),
249
258
  document_odf: new Set(['imageQuality', 'stripMetadata', 'stripUnusedStyles']),
250
259
  document_epub: new Set(['imageQuality', 'fontSubsetting', 'stripUnusedCss']),
@@ -310,10 +319,10 @@ function mergeLayer(acc, layer, source) {
310
319
  // can pin this hand-maintained allowlist to the generated contract metadata: every
311
320
  // field the resolver may emit MUST be a real contract option key for `compress`.
312
321
  export const KNOWN_WIRE_FIELDS = Object.freeze({
313
- image: new Set(['mode', 'quality', 'metadata', 'icc_profile', 'progressive', 'output_format']),
322
+ image: new Set(['quality', 'metadata', 'output_format']),
314
323
  audio: new Set(['bitrate', 'channels', 'sample_rate', 'normalize', 'trim_start', 'trim_end']),
315
324
  video: new Set(['codec', 'encoding_mode', 'crf', 'target_size_bytes', 'preset', 'width', 'height', 'fit', 'fps', 'faststart', 'audio_codec', 'audio_bitrate', 'trim_start', 'trim_end']),
316
- document_pdf: new Set(['profile', 'colorspace', 'pages', 'flatten_forms']),
325
+ document_pdf: new Set(['profile', 'grayscale']),
317
326
  document_office: new Set(['image_quality', 'strip_macros', 'strip_hidden_data', 'strip_unused_fonts']),
318
327
  document_odf: new Set(['image_quality', 'strip_metadata', 'strip_unused_styles']),
319
328
  document_epub: new Set(['image_quality', 'font_subsetting', 'strip_unused_css']),
@@ -335,19 +344,6 @@ function validateMerged(media, merged, explicitKeys, winners) {
335
344
  });
336
345
  }
337
346
  }
338
- // Image: `mode: Lossless` + `quality` set is invalid per the wire
339
- // contract (`depends_on: { mode: lossy }`). Runs on post-merge so a
340
- // caller passing explicit `quality` and inheriting `mode=Lossless`
341
- // from a client preset is caught.
342
- if (media === 'image' && merged.mode === 'lossless' && merged.quality !== undefined) {
343
- const snapshot = { ...merged };
344
- throw new GislConfigError(`Image compress: 'quality' is ignored when 'mode' is Lossless — passing both is a configuration bug.`, {
345
- reason: 'missing_dependency',
346
- conflictingFields: ['quality', 'mode'],
347
- resolvedSnapshot: Object.freeze(snapshot),
348
- suggestion: "Either drop 'quality' for lossless output, or set 'mode' to Lossy.",
349
- });
350
- }
351
347
  // Video: targetSize-derived encoding_mode='target_size' is only
352
348
  // valid for H264 today. Catch the combination post-merge — explicit
353
349
  // codec overrides a layered default and either resolution must end
@@ -406,7 +402,15 @@ function canonicalJson(value) {
406
402
  return '[' + value.map(canonicalJson).join(',') + ']';
407
403
  }
408
404
  const record = value;
409
- const keys = Object.keys(record).sort();
405
+ // Skip undefined-valued keys (mirror `JSON.stringify` object semantics,
406
+ // which omit undefined members). Without this, `canonicalJson(undefined)`
407
+ // would serialise the literal token `undefined` into the hash input — a
408
+ // latent foot-gun. The registered-cell records are already sparse (see
409
+ // `presetDefaultsCellRecord`), so this is defense-in-depth; the
410
+ // override-path anchors carry no undefined and are unaffected (SVQcoR1K).
411
+ const keys = Object.keys(record)
412
+ .filter((k) => record[k] !== undefined)
413
+ .sort();
410
414
  return ('{' +
411
415
  keys
412
416
  .map((k) => JSON.stringify(k) + ':' + canonicalJson(record[k]))
@@ -1,13 +1,11 @@
1
- import { PdfProfile, PdfColorspace, OptimizeFor } from '../../generated/sdk_spec/enums.js';
1
+ import { PdfProfile, OptimizeFor } from '../../generated/sdk_spec/enums.js';
2
2
  export interface DocumentPdfCompressPresetOptionsInput {
3
3
  readonly profile?: PdfProfile;
4
- readonly colorspace?: PdfColorspace;
5
- readonly flattenForms?: boolean;
4
+ readonly grayscale?: boolean;
6
5
  }
7
6
  export declare class DocumentPdfCompressPresetOptions {
8
7
  readonly profile?: PdfProfile;
9
- readonly colorspace?: PdfColorspace;
10
- readonly flattenForms?: boolean;
8
+ readonly grayscale?: boolean;
11
9
  private constructor();
12
10
  static from(input: DocumentPdfCompressPresetOptionsInput): DocumentPdfCompressPresetOptions;
13
11
  static shippedDefaultsFor(level: OptimizeFor): DocumentPdfCompressPresetOptions;
@@ -1,20 +1,20 @@
1
1
  // T4a — DocumentPdfCompressPresetOptions leaf DTO.
2
2
  //
3
- // Field set per ticket VhIj4S7T: PDF = 3 fields (profile, colorspace, flattenForms).
4
- // Deliberately excluded: `pages` (per-call content selection).
3
+ // Field set (2): profile, grayscale the worker-honored stable PDF controls
4
+ // (contracts v2.96.0 Acrobat-PDF realignment Lw1LseYr). The earlier
5
+ // {profile, colorspace, flattenForms} set was retired: colorspace + flatten_forms
6
+ // are `planned` (not read by the worker) so presets never emit them, and
7
+ // `image_dpi` + `pages` are per-call knobs, not preset cells.
5
8
  import { shippedDefaultsFor as f3ShippedDefaultsFor } from '../../generated/sdk_spec/presets.js';
6
9
  import { translateEnum } from './_translate.js';
7
10
  export class DocumentPdfCompressPresetOptions {
8
11
  profile;
9
- colorspace;
10
- flattenForms;
12
+ grayscale;
11
13
  constructor(input) {
12
14
  if (input.profile !== undefined)
13
15
  this.profile = input.profile;
14
- if (input.colorspace !== undefined)
15
- this.colorspace = input.colorspace;
16
- if (input.flattenForms !== undefined)
17
- this.flattenForms = input.flattenForms;
16
+ if (input.grayscale !== undefined)
17
+ this.grayscale = input.grayscale;
18
18
  Object.freeze(this);
19
19
  }
20
20
  static from(input) {
@@ -26,10 +26,8 @@ export class DocumentPdfCompressPresetOptions {
26
26
  const mut = input;
27
27
  if ('profile' in cell)
28
28
  mut.profile = translateEnum('PdfProfile', cell.profile);
29
- if ('colorspace' in cell)
30
- mut.colorspace = translateEnum('PdfColorspace', cell.colorspace);
31
- if ('flattenForms' in cell)
32
- mut.flattenForms = cell.flattenForms;
29
+ if ('grayscale' in cell)
30
+ mut.grayscale = cell.grayscale;
33
31
  return new DocumentPdfCompressPresetOptions(input);
34
32
  }
35
33
  }
@@ -1,18 +1,12 @@
1
- import { ImageMode, ImageMetadataPolicy, IccProfilePolicy, ImageFormat, OptimizeFor } from '../../generated/sdk_spec/enums.js';
1
+ import { ImageMetadataPolicy, ImageFormat, OptimizeFor } from '../../generated/sdk_spec/enums.js';
2
2
  export interface ImageCompressPresetOptionsInput {
3
- readonly mode?: ImageMode;
4
3
  readonly quality?: number;
5
4
  readonly metadata?: ImageMetadataPolicy;
6
- readonly iccProfile?: IccProfilePolicy;
7
- readonly progressive?: boolean;
8
5
  readonly outputFormat?: ImageFormat;
9
6
  }
10
7
  export declare class ImageCompressPresetOptions {
11
- readonly mode?: ImageMode;
12
8
  readonly quality?: number;
13
9
  readonly metadata?: ImageMetadataPolicy;
14
- readonly iccProfile?: IccProfilePolicy;
15
- readonly progressive?: boolean;
16
10
  readonly outputFormat?: ImageFormat;
17
11
  private constructor();
18
12
  /**
@@ -26,10 +20,9 @@ export declare class ImageCompressPresetOptions {
26
20
  * the given level. Reads the F3 PRESETS matrix and translates member
27
21
  * names to wire backing values.
28
22
  *
29
- * Note for OptimizeFor.Quality: the F3 PRESETS cell deliberately
30
- * omits `quality` because the contract has `depends_on: { mode: lossy }`
31
- * on the quality field — under `mode: Lossless` the API ignores
32
- * `quality`, so shipping a default would mislead callers.
23
+ * Since the v2.80.0 honesty pass the worker is lossy-only, so every
24
+ * level ships a concrete `quality` (Size 65 / Balanced 80 / Quality 92),
25
+ * `metadata: All`, and `outputFormat: Original`.
33
26
  */
34
27
  static shippedDefaultsFor(level: OptimizeFor): ImageCompressPresetOptions;
35
28
  }
@@ -7,32 +7,26 @@
7
7
  // values ARE the wire backing values — so the leaf DTO is wire-compatible
8
8
  // once the resolver (T4b) snake_cases the property names.
9
9
  //
10
- // Field set (6) per EsD1hs5u / contracts v2.60.0: image =
11
- // (mode, quality, metadata, iccProfile, progressive, outputFormat).
12
- // `width`/`height`/`fit`/`autoOrient` were REMOVED — the image-compress
13
- // worker never resized (resize-fit lives on thumbnail/convert; video keeps
14
- // its own fit). Trim / per-call knobs are deliberately excluded — they
15
- // belong on the per-call argument shape, not the preset cell.
10
+ // Field set (3) per contracts v2.80.0 compress.image honesty pass (Option B,
11
+ // lossy-only): image = (quality, metadata, outputFormat).
12
+ // `mode` + `iccProfile` were REMOVED — the worker is lossy-only and always
13
+ // strips metadata, so advertising a lossless mode or ICC-profile policy was
14
+ // an over-claim. `progressive` is still a per-JPEG wire option but is no
15
+ // longer carried in the preset cell. `width`/`height`/`fit`/`autoOrient`
16
+ // were removed earlier — the image-compress worker never resized (resize-fit
17
+ // lives on thumbnail/convert; video keeps its own fit). Per-call knobs are
18
+ // deliberately excluded — they belong on the per-call argument shape.
16
19
  import { shippedDefaultsFor as f3ShippedDefaultsFor } from '../../generated/sdk_spec/presets.js';
17
20
  import { translateEnum } from './_translate.js';
18
21
  export class ImageCompressPresetOptions {
19
- mode;
20
22
  quality;
21
23
  metadata;
22
- iccProfile;
23
- progressive;
24
24
  outputFormat;
25
25
  constructor(input) {
26
- if (input.mode !== undefined)
27
- this.mode = input.mode;
28
26
  if (input.quality !== undefined)
29
27
  this.quality = input.quality;
30
28
  if (input.metadata !== undefined)
31
29
  this.metadata = input.metadata;
32
- if (input.iccProfile !== undefined)
33
- this.iccProfile = input.iccProfile;
34
- if (input.progressive !== undefined)
35
- this.progressive = input.progressive;
36
30
  if (input.outputFormat !== undefined)
37
31
  this.outputFormat = input.outputFormat;
38
32
  Object.freeze(this);
@@ -50,25 +44,18 @@ export class ImageCompressPresetOptions {
50
44
  * the given level. Reads the F3 PRESETS matrix and translates member
51
45
  * names to wire backing values.
52
46
  *
53
- * Note for OptimizeFor.Quality: the F3 PRESETS cell deliberately
54
- * omits `quality` because the contract has `depends_on: { mode: lossy }`
55
- * on the quality field — under `mode: Lossless` the API ignores
56
- * `quality`, so shipping a default would mislead callers.
47
+ * Since the v2.80.0 honesty pass the worker is lossy-only, so every
48
+ * level ships a concrete `quality` (Size 65 / Balanced 80 / Quality 92),
49
+ * `metadata: All`, and `outputFormat: Original`.
57
50
  */
58
51
  static shippedDefaultsFor(level) {
59
52
  const cell = f3ShippedDefaultsFor('image_compress', level);
60
53
  const input = {};
61
54
  const mut = input;
62
- if ('mode' in cell)
63
- mut.mode = translateEnum('ImageMode', cell.mode);
64
55
  if ('quality' in cell)
65
56
  mut.quality = cell.quality;
66
57
  if ('metadata' in cell)
67
58
  mut.metadata = translateEnum('ImageMetadataPolicy', cell.metadata);
68
- if ('iccProfile' in cell)
69
- mut.iccProfile = translateEnum('IccProfilePolicy', cell.iccProfile);
70
- if ('progressive' in cell)
71
- mut.progressive = cell.progressive;
72
59
  if ('outputFormat' in cell)
73
60
  mut.outputFormat = translateEnum('ImageFormat', cell.outputFormat);
74
61
  return new ImageCompressPresetOptions(input);
@@ -13,7 +13,7 @@ export { DocumentPdfCompressPresetOptions, type DocumentPdfCompressPresetOptions
13
13
  export { DocumentOfficeCompressPresetOptions, type DocumentOfficeCompressPresetOptionsInput, } from './document_office_compress.js';
14
14
  export { DocumentOdfCompressPresetOptions, type DocumentOdfCompressPresetOptionsInput, } from './document_odf_compress.js';
15
15
  export { DocumentEpubCompressPresetOptions, type DocumentEpubCompressPresetOptionsInput, } from './document_epub_compress.js';
16
- export { OptimizeFor, ImageMode, ImageMetadataPolicy, IccProfilePolicy, ImageFormat, VideoCodec, VideoPreset, VideoFit, AudioBitrate, AudioCodec, AudioSampleRate, PdfProfile, PdfColorspace, } from '../../generated/sdk_spec/enums.js';
16
+ export { OptimizeFor, ImageMetadataPolicy, ImageFormat, VideoCodec, VideoPreset, VideoFit, AudioBitrate, AudioCodec, AudioSampleRate, PdfProfile, PdfColorspace, } from '../../generated/sdk_spec/enums.js';
17
17
  /** Supported media×op pairs for preset cells in T4a. Compress-only. */
18
18
  export type PresetMedia = 'image' | 'audio' | 'video' | 'document_pdf' | 'document_office' | 'document_odf' | 'document_epub';
19
19
  export type PresetOp = 'compress';
@@ -23,6 +23,26 @@ export type PresetOp = 'compress';
23
23
  * narrows the return automatically via the overload set below.
24
24
  */
25
25
  export type AnyPresetOptions = ImageCompressPresetOptions | AudioCompressPresetOptions | VideoCompressPresetOptions | DocumentPdfCompressPresetOptions | DocumentOfficeCompressPresetOptions | DocumentOdfCompressPresetOptions | DocumentEpubCompressPresetOptions;
26
+ /**
27
+ * Per-cell field-merge: parent fields ⊕ child fields where defined.
28
+ * Re-construct the leaf DTO via the matching `<LeafClass>.from(merged)`
29
+ * call so the result is a freshly-frozen `*PresetOptions` instance —
30
+ * NOT a mutated reference into either input. Used by
31
+ * {@link PresetDefaults.merge} when both parent and child registered
32
+ * the same `(cellKey, level)` tuple.
33
+ *
34
+ * `definedFieldsOf` filters undefined values out of each instance
35
+ * BEFORE the merge: with TS `useDefineForClassFields` (the ES2022
36
+ * default), `readonly outputFormat?: ImageFormat` declarations initialise
37
+ * the field as an enumerable own property with value `undefined` BEFORE
38
+ * the ctor body runs. A naive `Object.assign({}, parent, child)`
39
+ * therefore lets child's `undefined` overwrite parent's defined value
40
+ * — caught by CI on PR #125 first run. Filter-then-spread restores
41
+ * the documented merge-not-replace semantics.
42
+ *
43
+ * @internal
44
+ */
45
+ export declare function definedFieldsOf<T extends object>(opts: T): Partial<Record<string, unknown>>;
26
46
  export declare class PresetDefaults {
27
47
  private readonly cells;
28
48
  private constructor();