Transformation Language Specification

Version v0 (https://transformation-serializer.cloudinary.com/api/v0, https://transformation-serializer.cloudinary.com/schema/v0)
Status Draft
Category Standards Track for the transformation serializer

Abstract#

This document specifies the Cloudinary transformation language as the transformation serializer understands it: the lexical and syntactic structure of a transformation string, the structured model that represents one, the JSON Schema that defines the model, the expressions and conditions the language allows, and the behaviour of the encoder and decoder that translate between the two forms. It defines a canonical representation so that one transformation has one model and one string.

It is not a replacement for the Cloudinary transformation reference. That reference describes every action and property delivery supports; this document describes the language those actions are written in, and leaves the catalogue of actions to the schema.

Status of This Document#

This is a working draft published with each version of the service. It specifies the language and the behaviour of a conforming serializer; where an implementation differs, the implementation is what changes.

Index#

  1. Introduction
  2. Schema
  3. Language
  4. Serializer
  5. Security Considerations
  6. References

1. Introduction#

A Cloudinary transformation is written into a delivery URL as a string such as c_scale,w_200/e_blur:500. The same transformation can be represented as a structured model, a JSON array of actions, which is what an editor, a validator or a program generating URLs works with:

[
  { "actionType": "scale", "dimensions": { "width": 200 } },
  { "actionType": "blur", "strength": 500 }
]

The transformation serializer translates between the two: decoding a string into a model and encoding a model into a string. This document specifies the language in enough detail that two implementations would agree on both directions.

1.1 Scope#

This document specifies:

  • the characters, tokens and structure of a transformation string (Section 3);
  • the values, variables, expressions and conditions the string may contain, and how each is represented in the model (Section 3);
  • the JSON Schema that defines the model and the conventions it follows (Section 2);
  • the canonical representation and the round-trip guarantees that follow from it (Section 3.7);
  • the behaviour of the encoder and the three-phase decoder (Section 4).

It does not specify which actions exist, what their parameters mean, or what delivery does with them. That is the schema's responsibility, one file per action, and the Cloudinary transformation reference is the authority on their meaning.

1.2 Conventions and Requirement Keywords#

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.

The string is described in ABNF [RFC5234], with every rule named after the lexer token or parser node that produces it (Section 4.3), so the grammar and the code use one vocabulary. The model is described as TypeScript types named after the schema's titles, exactly as pnpm types:generate emits them, and each type is given the URL form it is written as. Examples show a transformation string and the model it corresponds to; every example in this document was produced by the serializer at this version, or, for expressions, validated against the schema and checked against delivery.

1.3 Terminology#

Term Meaning
Transformation string The text between the upload type and the public id in a delivery URL: c_scale,w_200/e_blur:500.
Component One /-separated part of a transformation string. Components are applied in order, each to the output of the previous one. Also called a segment.
Qualifier One ,-separated part of a component: a name, an underscore, and a value (w_200), or a name and a prefix (c_scale, e_blur:500).
Action One element of the model: an object with an actionType and the properties that action takes. One component usually becomes one action; some qualifiers merge into one action and some actions span several components.
Model A JSON array of actions and variable assignments, in the order they are written. Validated by the schema in Section 2.
Expression A qualifier value computed from asset properties, variables and literals, such as iw_div_2 (Section 3.5).
Condition A boolean expression guarding a component, written as if_… (Section 3.6).
Canonical form The one string the encoder produces for a model, and the one model the decoder produces for a string (Section 3.7).
Delivery The Cloudinary delivery pipeline that evaluates a transformation URL. The authority on what a transformation means.

2. Schema#

2.1 JSON Schema#

The model is defined by a tree of JSON Schema documents written to JSON Schema draft 2020-12. The root is transformation.json; a model MUST validate against it to be a model in the sense of this document. The JSON Schema page is the practical guide to fetching and using it.

curl -s https://transformation-serializer.cloudinary.com/schema/v0/transformation.json
Schema Covers
/schema/v0/transformation.json any transformation: image or video
/schema/v0/image_transformation.json image actions only
/schema/v0/video_transformation.json video actions only
/schema/v0/actions/<group>/<action>.json one file per action
/schema/v0/qualifiers/<qualifier>.json shapes shared by several actions: dimensions, colour, gravity, …
/schema/v0/grammar/<element>.json the values, references, assignment, expressions and conditions of Sections 3.3–3.6

2.2 Organisation#

schemas/
├── transformation.json          anyOf: image_transformation | video_transformation
├── image_transformation.json    array of image actions
├── video_transformation.json    array of video actions
├── actions/
│   ├── resize/scale.json, crop.json, …
│   ├── effect/blur.json, …
│   ├── other/if.json, custom.json, …
│   └── <group>/…
├── qualifiers/
│   └── dimensions.json, width.json, height.json, gravity.json, color.json, …
└── grammar/
    ├── numeric_value.json, string_value.json, property_value.json, collection_property_value.json
    ├── variable_reference.json, context_reference.json, metadata_reference.json, asset_reference.json
    ├── numeric_expression.json, product_expression.json, sum_expression.json, arithmetic_expression.json
    ├── comparison_operand.json, comparison_expression.json, and_expression.json, or_expression.json, boolean_expression.json
    └── variable_assignment.json

Each transformation schema is an array whose items are a union (anyOf) of the variable assignment and the action schemas it admits. Each action schema is an object with a constant actionType, the qualifiers it takes as properties, and additionalProperties: false. Qualifier schemas are referenced from the actions that share them, and grammar schemas from the qualifiers whose value is grammar.

2.3 Naming and Referencing#

  • File names are snake_case and name the thing defined: round_corners.json, product_expression.json.

  • title is the PascalCase name of the generated type: RoundCorners, ProductExpression. Every schema that defines a type MUST carry a title; a schema that is only a union MAY.

  • $id is absolute and canonical. Every schema hardcodes the same origin:

    { "$id": "https://transformation-serializer.cloudinary.com/schema/v0/transformation.json" }

    A schema is identified by its $id, not by the URL it was fetched from, so two copies obtained from different hosts are the same schema to a validator. The $id carries the API version, and the file is served at that path; a new contract gets a new version segment and new identities.

  • $ref is always relative (../qualifiers/dimensions.json, ./numeric_value.json), so the tree resolves against the $id wherever it is served from. A validator that must stay off the network SHOULD be pointed at a local copy of the whole tree.

  • Discriminators. An action is identified by actionType; a grammar node by type, including the variable assignment that sits among the actions; a union of qualifier shapes by a <name>Type property (gravityType, backgroundType). Discriminator values are the file name of the schema that defines the variant, with two documented exceptions in the grammar (Section 3.5). A NumericValue has no discriminator: it is a bare number or string (Section 3.3).

  • Unions are anyOf for action lists and oneOf for grammar alternatives, where exactly one branch MUST match.

  • group and assetTypes are custom keywords on action schemas, naming the action's category and whether it applies to images, videos or both. Validators ignore them.

2.4 Generated Types#

The serializer's Transformation type is generated from the schema with json-schema-to-typescript, so anything the schema allows, the type allows, and nothing else:

npx json2ts -i src/schemas/transformation.json --cwd src/schemas \
  -o src/types/transformation.d.ts --maxItems=-1

--maxItems=-1 matters: without it, fixed-length array schemas become tuple types that reject valid input. Consumers who generate their own types SHOULD use the same options so that the types agree.

2.5 Validity and Delivery#

A model that validates is well-formed. The schema is written so that a well-formed model also works, that is, encodes to a string delivery accepts, and a schema-valid model that delivery rejects is a schema defect. The guarantee holds for what the schema models; it cannot see every rule of delivery (Section 3.5.5 lists the known exceptions), and delivery remains the authority.

import Ajv2020 from 'ajv/dist/2020.js';

const ajv = new Ajv2020({ loadSchema: async (uri) => (await fetch(uri)).json() });
const schema = await (await fetch('https://transformation-serializer.cloudinary.com/schema/v0/transformation.json')).json();
const validate = await ajv.compileAsync(schema);

validate([{ actionType: 'scale', dimensions: { width: 200 } }]); // true

3. Language#

3.1 Lexical Structure#

A transformation string is a sequence of Unicode characters. The lexer (Section 4.3.1) divides it into tokens; the rules below are named after the TokenType each produces. Characters not covered by any rule MUST be rejected.

IDENTIFIER = 1*( ALPHA / DIGIT / "%" / "@" / "#" / SP )   ; trailing spaces become WHITESPACE
NUMBER     = [ "-" ] 1*DIGIT [ "." *DIGIT ]         ; a bare trailing "." only before , / _ : or the end
STRING     = "!" *( %x00-20 / %x22-7F ) "!"              ; any character but "!", delimiters stripped
VARIABLE   = "$" 1*( ALPHA / DIGIT / "%" / "@" / "#" )    ; sigil stripped
UNDERSCORE = "_"      COLON  = ":"      SEMICOLON = ";"    COMMA  = ","      SLASH  = "/"
DOT        = "."      MINUS  = "-"      PLUS      = "+"    ASTERISK = "*"    EQUALS = "="
QUOTE      = "'"      DQUOTE = %x22     LPAREN    = "("    RPAREN = ")"
WHITESPACE = 1*SP                                          ; only where not absorbed into IDENTIFIER

Three rules decide ambiguous input:

  1. A - directly followed by a digit begins a NUMBER; otherwise it is MINUS. a_-20 is IDENTIFIER(a) UNDERSCORE NUMBER(-20); a-b is IDENTIFIER(a) MINUS IDENTIFIER(b).
  2. A . between digits is part of a NUMBER, and so is a . that ends one right before ,, /, _, : or the end of the string; elsewhere it is DOT. a_2.5 is one number, w_1. is NUMBER(1.), a_vflip.20 is IDENTIFIER(vflip) DOT NUMBER(20), and w_1.x is an error.
  3. Spaces inside a run of identifier characters do not end the IDENTIFIER. a b is one token; this keeps text overlays intact. Spaces next to punctuation, as in a - b, are WHITESPACE tokens. Newlines produce no token.

% is an identifier character, so a percent-encoded sequence such as %20 is carried inside an IDENTIFIER rather than decoded; the serializer neither decodes nor encodes URL escapes.

3.2 Syntactic Structure#

The parser (Section 4.3.2) builds a tree from the tokens; the rules below are named after the node each produces.

Transformation     = Segment *( SLASH Segment )
Segment            = SegmentBody *( COMMA SegmentBody )
SegmentBody        = Qualifier / VariableAssignment / Layer / LayerApply
Qualifier          = IDENTIFIER UNDERSCORE RawValue                       ; w_200, f_jpg, bo_5px_solid_red
                   / IDENTIFIER UNDERSCORE IDENTIFIER [ COLON RawValue ]  ; c_scale, e_blur:500, l_text:Arial_20:Hi
VariableAssignment = VARIABLE UNDERSCORE RawValue                         ; $w_400, $n_ctx:!k!   (Section 3.4)
LayerApply         = "fl_layer_apply"
Layer              = Qualifier Segment *( SLASH Segment ) LayerApply       ; see below
RawValue           = 1*token                                               ; every token up to the next COMMA or SLASH
  • A Segment is applied to the output of the previous one; the qualifiers within it describe one step and are unordered (Section 3.7 fixes their order in the canonical form).
  • Only the names c, e, fl, l and u take the second Qualifier form, with a prefix. For every other name, everything after the first UNDERSCORE up to the next COMMA or SLASH is the RawValue: bo_5px_solid_red is one Qualifier whose value is 5px_solid_red, not three.
  • A Layer begins with an l_ (overlay) or u_ (underlay) Qualifier and extends to the Segment containing the matching LayerApply. The segments between are the layer's own transformation; the qualifiers beside l_/u_ in its first segment describe the layer source, and those beside LayerApply describe its placement. Layers nest.
  • An unrecognised Qualifier is not an error. It MUST be preserved: the decoder represents it as a custom action holding its text, and the encoder emits that text unchanged (Section 3.7).

3.3 Values#

A value is known when the string is written: it always exists, and its type is fixed.

Numbers. An integer (200) or a decimal (0.5, 1.0). In dimension qualifiers an integer is pixels and a decimal is a multiple of the original, so w_1 is one pixel, w_1.0 the full width and w_0.5 half of it. The model holds a number as a NumericValue: an integer as a JSON number, a decimal as a string holding its text verbatim. w_200 is 200, w_1.0 is "1.0", and both encode back as written. A decimal MUST NOT be reformatted (3.00 is not 3.0 to delivery). An number written with a trailing dot (w_1.) is a lexical NUMBER, so it no longer stops the decoder, but it is not a NumericValue: the decoder keeps its text as written. Delivery reads 1. as 1.0 in w, h, dpr, z and o, and rejects it in x, y, ar, q, an assignment and arithmetic. An integer is canonical without leading zeros, since its value is all delivery reads: w_007 decodes to 7 and encodes as w_7, the same image. An integer the model cannot hold exactly, beyond ±(2^53 − 1), MUST NOT be decoded as a number, since rounding it would change the value; the decoder keeps its text. An encoder MUST write an integer in plain digits, never in exponent notation. A NumericValue is not wrapped in an object, so the same type serves every numeric position: a qualifier's whole value, an operand, an assigned value. A ratio (3:4) is not a NumericValue; ar takes one as its own string.

Strings. Text delimited by !…!: !sale!, !sale:in_stock!. A string may contain any character except !; it is used as a variable's value (Section 3.4) and as a comparison operand (Section 3.6). The model holds it as a StringValue without the delimiters, { "type": "string_value", "value": "sale" }. It keeps an object where a number does not, because a bare string is already a decimal NumericValue.

Properties. An asset property, written as its code (Appendix B): iw, fc. The model holds it as a PropertyValue under its long name, { "type": "property_value", "name": "initial_width" }. It is read at delivery time but is still a value: a property always exists and has a known type.

References. $w, ctx:!key!, md:!id! and ref:!id! point at something resolved only when the string is evaluated: it may be missing, and its type is unknown until then. Each is a grammar node of its own, never a string:

Type Written as Model
VariableReference $ + name { "type": "variable_reference", "name": "w" }
ContextReference ctx:!key! { "type": "context_reference", "key": "productType" }
MetadataReference md:!id! { "type": "metadata_reference", "key": "stock-level" }
AssetReference ref:!publicId! { "type": "asset_reference", "publicId": "cloudinary_icon" }

A variable read before it is assigned gets a 400 response from delivery, and so does an asset reference to an asset that does not exist; a missing context key evaluates to nothing. An AssetReference is only ever a variable's value (Section 3.4).

Identifiers name actions, modes, formats, colours and fonts: scale, hflip, jpg, rgb:ff33cc, Arial. Their meaning is the action's, and the schema enumerates them where the set is closed.

3.4 Variables#

A variable is declared with an assignment, $name_<value>, and read through a VariableReference, $name (Section 3.3), wherever a value may be one.

The value of a VariableAssignment is any of six kinds, and the kind is the value's own type:

Written as Value Example
a number, property, $name or conversion NumericExpression (Section 3.5) $w_400 · $ratio_0.8 · $w_iw
arithmetic ArithmeticExpression $w_iw_div_2
!text! StringValue $label_!sale!
ctx:!key! ContextReference $name_ctx:!productType!
md:!id! MetadataReference $stock_md:!stock-level!
ref:!publicId! AssetReference $logo_ref:!cloudinary_icon!

A context or metadata lookup converted with _to_i or _to_f is a ConversionExpression of the reference, and so a NumericExpression: $n_ctx:!breadth!_to_i.

A name MUST begin with a letter and contain only letters and digits. Within a component, assignments are evaluated before the qualifiers that use them, whatever their written order, so c_scale,w_$w,$w_400 is valid and means the same as $w_400,c_scale,w_$w. An assignment inside a conditional component is evaluated only if the condition holds.

Each assignment is one VariableAssignment in the model. It is a grammar node, not an action: it sits among the actions in model order and acts on nothing by itself, and it is discriminated by type, not actionType.

String Model
$w_400 { "type": "variable_assignment", "name": "w", "value": 400 }
$label_!sale! { …, "name": "label", "value": { "type": "string_value", "value": "sale" } }
$stock_md:!stock-level!_to_i { …, "name": "stock", "value": { "type": "conversion_expression", "to": "integer", "value": { "type": "metadata_reference", "key": "stock-level" } } }
$logo_ref:!cloudinary_icon! { …, "name": "logo", "value": { "type": "asset_reference", "publicId": "cloudinary_icon" } }

A reference in a qualifier's value is a VariableReference, whether it is the whole value or an operand:

$w_400/c_scale,w_$w   ⇄   [{ "type": "variable_assignment", "name": "w", "value": 400 },
                           { "actionType": "scale", "dimensions": { "width": { "type": "variable_reference", "name": "w" } } }]

A decoder MUST produce one VariableAssignment per assignment, and an encoder MUST write each one back as $name_<value>, so that a declaration survives a round trip. The $(name) interpolation form used inside text layers is outside this specification.

3.5 Expressions#

An expression is a qualifier value computed from asset properties, variables and literals: c_scale,w_iw_div_2 is half the original width. This section specifies the language of expressions and the model that represents them; the model is the subject of the expressions RFC, which records the reasoning and the verification behind it.

3.5.1 Form#

An arithmetic expression is a sequence of operands joined by operators, written flat:

<operand> _ <operator> _ <operand> _ <operator> _ <operand> …      100_add_100_mul_10, iw_div_2, $w_add_50
Type Written as
NumericValue the number verbatim: 2, 0.5
VariableReference $ + name: $w
PropertyValue the property's code (Appendix B): iw, w, ar
ConversionExpression a reference + _to_i or _to_f: $s_to_i, ctx:!n!_to_f
SumExpression, ProductExpression <left>_<operator>_<right>, operator one of add sub · mul div mod pow

The form is flat: there is no grouping token. w_(10_add_10)_mul_2 and w_!10_add_10!_mul_2 both get a 400 response from delivery. The only way to group is a variable: $t_10_add_10/c_scale,w_$t_mul_2.

3.5.2 Evaluation#

Delivery evaluates an arithmetic string with two precedence levels, both left to right:

Level Operators Note
tighter mul div mod pow pow is on this level, not above it: 2_mul_3_pow_4 is (2 mul 3) pow 4 = 1296
looser add sub

So 100_add_100_mul_10 is 1100, 100_sub_10_sub_20 is 70 and 1000_div_10_div_2 is 50. Arithmetic is floating point; when the result becomes a dimension it is rounded once to whole pixels (10_div_3_mul_100 gives 333). A property is read when the component is evaluated: w and h are the current size, after earlier components, while iw and ih are the original and never change.

A string is not accepted in arithmetic. A string-valued variable MUST be converted with _to_i or _to_f before use: $s_!100!/c_scale,w_$s_to_i_mul_2 is accepted, w_100_add_$s is not.

3.5.3 Model#

Because the string has no grouping and delivery gives it exactly one reading, the model of an expression is the tree delivery evaluates. Each operator level is a node whose right side holds only tighter levels and whose left side MAY also hold the level itself (left-to-right evaluation):

type NumericExpression = NumericValue | PropertyValue | VariableReference | ConversionExpression;

type ProductExpression = {
  type: 'arithmetic_expression';
  operator: 'mul' | 'div' | 'mod' | 'pow';
  left: ProductExpression | NumericExpression;
  right: NumericExpression;
};
type SumExpression = {
  type: 'arithmetic_expression';
  operator: 'add' | 'sub';
  left: ArithmeticExpression | NumericExpression;
  right: ProductExpression | NumericExpression;
};
type ArithmeticExpression = SumExpression | ProductExpression;

The leaves are the NumericExpressions: a NumericValue (2, or "0.5" for a decimal), a VariableReference ({ "type": "variable_reference", "name": "w" }), a PropertyValue ({ "type": "property_value", "name": "initial_width" }, the long name; Appendix B maps it to the code) and a { "type": "conversion_expression", "to": "integer" | "float", "value": … }, whose value is a variable, context or metadata reference; a literal needs no conversion. A NumericExpression is also, on its own, a qualifier's whole value (Section 3.5.4). Both tiers carry "type": "arithmetic_expression" and are told apart by operator, so a single-operator model reads the same whichever tier it belongs to.

A schema for this language MUST accept exactly one model per string and MUST NOT accept a model whose in-order printing re-parses to a different tree. A tree needs parentheses, and therefore has no string, exactly when a child binds looser than its parent or a right child binds as tight as its parent; the shapes above make both unconstructible.

c_scale,w_100_add_100_mul_10 — 1100 wide. The product nests on the right; mul(add(100, 100), 10) = 2000 has no string and cannot be built.

[
  {
    "actionType": "scale",
    "dimensions": {
      "width": {
        "type": "arithmetic_expression",
        "operator": "add",
        "left": 100,
        "right": {
          "type": "arithmetic_expression",
          "operator": "mul",
          "left": 100,
          "right": 10
        }
      }
    }
  }
]

c_scale,w_100_sub_10_sub_20 — 70 wide. Repeats nest on the left.

[
  {
    "actionType": "scale",
    "dimensions": {
      "width": {
        "type": "arithmetic_expression",
        "operator": "sub",
        "left": {
          "type": "arithmetic_expression",
          "operator": "sub",
          "left": 100,
          "right": 10
        },
        "right": 20
      }
    }
  }
]

$s_!100!/c_scale,w_$s_to_i_mul_2 — 200 wide; a converted string variable as an operand.

[
  {
    "type": "variable_assignment",
    "name": "s",
    "value": {
      "type": "string_value",
      "value": "100"
    }
  },
  {
    "actionType": "scale",
    "dimensions": {
      "width": {
        "type": "arithmetic_expression",
        "operator": "mul",
        "left": {
          "type": "conversion_expression",
          "to": "integer",
          "value": {
            "type": "variable_reference",
            "name": "s"
          }
        },
        "right": 2
      }
    }
  }
]

3.5.4 Where expressions are accepted#

A numeric qualifier's value is grammar. For most, it is a NumericExpression (w_300, w_1.0, w_iw, w_$w, w_$s_to_i) or an ArithmeticExpression, with no plain-string branch beside them: "$w" and a VariableReference would be two models of one string. q, a, o and r keep their range-checked plain number, and take every other NumericExpression — a VariableReference, a PropertyValue or a ConversionExpression — or an ArithmeticExpression beside it. Every row was verified on delivery: each form produced the same output as the plain value it stands for.

Qualifier Value Verified forms
w, h NumericExpression · ArithmeticExpression w_iw, w_$w, w_$s_to_i, w_iw_div_2, h_150_mul_2
x, y NumericExpression · ArithmeticExpression x_iw_div_4, x_250_mul_2
ar ratio string, ignore_aspect_ratio · NumericExpression · ArithmeticExpression ar_iar, ar_$v, ar_$s_to_i, ar_iw_div_ih, ar_1_add_0.5, ar_$s_to_f
z NumericExpression · ArithmeticExpression z_1.5, z_$v, z_fc, z_1_add_1, z_iw_div_1000, z_$v_add_1
dpr auto · NumericExpression · ArithmeticExpression dpr_2.0, dpr_$v, dpr_pc, dpr_1_add_1, dpr_iw_div_1000, dpr_$s_to_f
dn NumericExpression · ArithmeticExpression dn_$v, dn_pc, dn_100_mul_3, dn_$v_mul_3
q 1–100, auto and its modes · VariableReference · PropertyValue · ConversionExpression · ArithmeticExpression q_pc, q_$s_to_i, q_iw_div_40, q_200_mod_150, q_$s_to_i_mul_2, q_0.5_mul_100
a integer, flip modes · VariableReference · PropertyValue · ConversionExpression · ArithmeticExpression a_pc, a_$s_to_i, a_90_div_2, a_iw_div_40, a_0_sub_45, a_hflip.90_div_2
o 0–100 · VariableReference · PropertyValue · ConversionExpression · ArithmeticExpression o_pc, o_$s_to_i, o_100_div_2, o_iw_div_40, o_$s_to_f_mul_100
r 1–4 corners, each an integer · VariableReference · PropertyValue · ConversionExpression · ArithmeticExpression; max r_pc, r_$s_to_i:pc:30:40, r_iw_div_100, r_10_mul_2:20_add_20, r_5_mul_2:20:30:40
a variable's value Section 3.4 $w_iw_div_2
either side of a comparison Section 3.6 if_w_div_2_gt_900

Five limits of delivery that the schema expresses or the author must know:

  • A computed quality carries no suffix. q_50:qmax_10 and q_50:444 work; q_25_mul_2:qmax_10, q_$v:qmax_10, q_pc:qmax_10 and q_25_mul_2:444 get a 400 response. A quality action whose level is anything but a plain number or mode — a variable, a property, a conversion, an expression — MUST NOT carry quantization or chromaSubSampling; the schema enforces this.
  • An angle has no decimal point. In a, . separates several rotations (a_hflip.90), so a_45_mul_1.5 is read as 45_mul_1 followed by 5, that is 50°, and a_67.5 as 67 then 5. A NumericValue inside an angle MUST be an integer; an expression MAY still evaluate to a fraction (a_90_div_4 rotates by 22.5°).
  • Effects and the border width take a variable, not an expression. e_blur:$v and bo_$v_solid_red work; e_blur:200_mul_2, e_blur:$v_mul_2, e_blur:$s_to_i, bo_2_add_3_solid_red and bo_$vpx_solid_red get a 400 response, and e_tint:25_mul_2 is accepted but the expression is ignored. Those qualifiers keep their plain branch; variable references in them are variable support, not expression support, and are specified separately. pg accepts neither.
  • q and r take no context or metadata reference. Both split their value at : — q for its suffixes (q_auto:good, q_50:qmax_10), r between corners — and ctx:!key! and md:!key! contain one. q_ctx:!q!_to_i, q_md:!q!_to_i and r_ctx:!r!_to_i get a 400 response, alone or in arithmetic (q_ctx:!q!_to_i_mul_2). The schema does not express this: a ConversionExpression converts any reference. A variable works (q_$s_to_i, r_$s_to_i), and o and a take context references (o_ctx:!o!_to_i).
  • dpr and dn read only the properties known before the asset is processed: w, h, iw, ih, ar, iar, pc and idn. The others (tar, cp, fc, ccc, rc, ils, px, py, du, idu) get a 400 response, alone or in arithmetic: c_scale,dpr_fc,w_100 reports dpr expressions must be known before execution, and dn_fc reports Requested density is too large. The schema does not express this; PropertyValue is shared by every qualifier.

3.5.5 Limits of the schema#

Two rules of delivery depend on what a variable holds, which the schema cannot see:

  • a string variable in arithmetic needs _to_i/_to_f ($s_!100!/c_scale,w_100_add_$s is rejected);
  • a string cannot be ordered against a number ($a_!x!/if_$a_lt_1 is rejected; equality is accepted and false).

Both are valid models. An implementation that follows assignments MAY report them; the schema does not.

The schema does not constrain the sign of a numeric value either, since one NumericValue serves every qualifier. c_scale,w_0 is a valid model and gets a 400 response (Invalid width in transformation: 0); a negative width and an expression that evaluates to zero are accepted by delivery.

A conforming decoder MUST produce the trees of Section 3.5.3 for an expression, and a conforming encoder MUST print a tree in order, left _ operator _ right, with no further logic.

3.6 Conditions#

A condition guards a component: if_w_gt_1000/c_scale,w_1000/if_end, optionally with an if_else branch. The guard is a boolean expression over comparisons.

Type Written as
ComparisonExpression <left>_<operator>_<right>, operator one of eq ne lt gt lte gte in nin
AndExpression <left>_and_<right>
OrExpression <left>_or_<right>
VariableCheckExpression isdef_$name or isndef_$name
StringValue !text!
CollectionPropertyValue tags · rn · pgnames · ctx · md
ContextReference, MetadataReference ctx:!key!, md:!id!
anything numeric as in Section 3.5.1

Delivery evaluates and before or, both left to right, and arithmetic before comparison, so if_w_gt_1500_add_1000 means w > 2500. Comparisons do not chain: w_gt_1_gt_0 is rejected. Strings compare as text (!10!_lt_!9! is true); in and nin read a string on the right as a :-separated list; a string and a number never compare equal.

The model follows the same construction as Section 3.5.3, with three levels:

type BooleanExpression = OrExpression | AndExpression | ComparisonExpression | VariableCheckExpression;

type OrExpression = {
  type: 'or_expression';
  left: BooleanExpression;
  right: AndExpression | ComparisonExpression | VariableCheckExpression;
};
type AndExpression = {
  type: 'and_expression';
  left: AndExpression | ComparisonExpression | VariableCheckExpression;
  right: ComparisonExpression | VariableCheckExpression;
};
type ComparisonExpression = {
  type: 'comparison_expression';
  operator: 'eq' | 'ne' | 'lt' | 'gt' | 'lte' | 'gte' | 'in' | 'nin';
  left: ComparisonOperand;
  right: ComparisonOperand;
};
type VariableCheckExpression = { type: 'variable_defined_expression' | 'variable_not_defined_expression'; variable: VariableReference };
type ComparisonOperand = ArithmeticExpression | NumericExpression | StringValue | CollectionPropertyValue | ContextReference | MetadataReference;

The if action holds the condition and the guarded transformations, in the shape of an ESTree IfStatement:

if_w_gt_1000_and_isdef_$big/e_grayscale/if_end

[
  {
    "actionType": "if",
    "condition": {
      "type": "and_expression",
      "left": {
        "type": "comparison_expression",
        "operator": "gt",
        "left": {
          "type": "property_value",
          "name": "width"
        },
        "right": 1000
      },
      "right": {
        "type": "variable_defined_expression",
        "variable": {
          "type": "variable_reference",
          "name": "big"
        }
      }
    },
    "consequent": [
      {
        "actionType": "grayscale"
      }
    ]
  }
]

if_iw_lte_400/c_fill,h_120,w_240/if_else/c_fill,h_240,w_400/if_end

[
  {
    "actionType": "if",
    "condition": {
      "type": "comparison_expression",
      "operator": "lte",
      "left": {
        "type": "property_value",
        "name": "initial_width"
      },
      "right": 400
    },
    "consequent": [
      {
        "actionType": "fill",
        "dimensions": {
          "width": 240,
          "height": 120
        }
      }
    ],
    "alternate": [
      {
        "actionType": "fill",
        "dimensions": {
          "width": 400,
          "height": 240
        }
      }
    ]
  }
]

A decoder MUST produce one if action for the components from an if_ to its matching if_end, with the components between as consequent and the components after an if_else as alternate. An encoder MUST write an if action as if_<condition>/<consequent>/if_end, or as if_<condition>/<consequent>/if_else/<alternate>/if_end when alternate is present.

3.7 Canonical Representation#

A transformation has many spellings (w_500,h_300 and h_300,w_500 are the same transformation) and, without the rules of this section, could have several models. The canonical representation makes both unique.

Canonical string. The encoder MUST produce, for a model, the string in which:

  1. actions appear in model order, each as one component, joined by /;
  2. within a component, qualifiers are sorted by their encoded text, ascending, so { "width": 500, "height": 300 } encodes to c_scale,h_300,w_500;
  3. a number is written as the model holds it (200, "1.0"), an integer in plain digits, and an expression in order, left _ operator _ right;
  4. a custom action contributes its text verbatim, with leading and trailing / removed;
  5. an action that encodes to nothing is omitted, and an empty model encodes to the empty string.

Canonical model. The decoder MUST produce, for a string, the model in which:

  1. each component's qualifiers are compiled into actions by the rules of Section 4.3.3, with resize actions before the others;
  2. a property is present only when its qualifier is present; the decoder MUST NOT supply defaults;
  3. an expression is the one tree of Section 3.5.3, and an integer has no leading zeros (Section 3.3);
  4. an unrecognised qualifier is a custom action holding its text.

Round trips. With these rules:

  • decode(encode(m)) is m for every canonical model m;
  • encode(decode(s)) is the canonical spelling of s for every string s, which equals s when s is already canonical and differs from it only in qualifier order, in an integer's leading zeros, and in text the serializer does not model, which custom preserves.

A round trip is therefore stable but not byte-identical for a non-canonical input, and the canonical spelling is what a consumer SHOULD store.

"c_scale,w_500,h_300"  →  decode  →  [{ "actionType": "scale", "dimensions": { "width": 500, "height": 300 } }]
                        →  encode  →  "c_scale,h_300,w_500"

4. Serializer#

4.1 Entrypoints#

The serializer is one implementation reached three ways:

Entrypoint Use
HTTP API POST /api/v0/encode, POST /api/v0/decode
ESM bundle import { encode, decode } from 'https://transformation-serializer.cloudinary.com/esm/latest/serializer.mjs'
Playground the bundle, in a page

encode(model) returns the canonical string; decode(string) returns the canonical model. Both are pure: the same input always gives the same output, and nothing is sent anywhere by the bundle.

4.2 Encoder#

The encoder maps each action to its text through a registry keyed by actionType, then joins the results with /:

model → encodeAction(each) → filter empty → join("/")

Each action encoder returns the qualifiers of that action and sorts them; the sort is what makes the output canonical (Section 3.7).

Model String
[{ "actionType": "scale", "dimensions": { "width": 200 } }, { "actionType": "blur", "strength": 500 }] c_scale,w_200/e_blur:500
[{ "actionType": "scale", "dimensions": { "width": 200, "height": 100 } }] c_scale,h_100,w_200
[{ "actionType": "crop", "dimensions": { "width": 300, "aspectRatio": "1:1" }, "gravity": { "gravityType": "auto", "autoFocus": [{ "object": "face" }] } }] ar_1:1,c_crop,g_auto:face,w_300
[{ "actionType": "blur", "strength": 500 }, { "actionType": "sepia" }, { "actionType": "grayscale" }] e_blur:500/e_sepia/e_grayscale
[{ "actionType": "quality", "level": 80, "quantization": 10 }] q_80:qmax_10
[{ "actionType": "border", "width": 5, "style": "solid", "color": "red" }] bo_5px_solid_red
[{ "actionType": "overlay", "source": { "sourceType": "image", "publicId": "logo", "transformation": [{ "actionType": "blur", "strength": 500 }] } }] l_logo/e_blur:500/fl_layer_apply
[{ "actionType": "overlay", "source": { "sourceType": "image", "publicId": "logo" }, "position": { "gravity": { "gravityType": "direction", "compass": "north" }, "offsetX": 10, "offsetY": 20 } }] l_logo/fl_layer_apply,g_north,x_10,y_20
[{ "actionType": "custom", "transformationString": "/e_blur:50/e_grayscale/" }] e_blur:50/e_grayscale
[{ "actionType": "scale", "dimensions": { "width": "1.0" } }] c_scale,w_1.0
[{ "type": "variable_assignment", "name": "w", "value": 400 }, { "actionType": "scale", "dimensions": { "width": { "type": "variable_reference", "name": "w" } } }] $w_400/c_scale,w_$w
[] (empty string)

A layer encodes as its source qualifier, its transformation's components, and a closing fl_layer_apply carrying the placement qualifiers; nested layers nest the same way.

Every action the schema admits MUST have an encoding; an action the encoder does not know is a defect, not an empty string. The encoder assumes a schema-valid model and does not validate it.

4.3 Decoder#

The decoder is a three-phase pipeline. Each phase has one responsibility and can be run alone.

"c_scale,w_200/e_blur:500"
  → lexer    → IDENTIFIER(c) UNDERSCORE IDENTIFIER(scale) COMMA IDENTIFIER(w) UNDERSCORE NUMBER(200) SLASH …
  → parser   → Transformation { segments: [ Segment { body: [ Qualifier(c, scale), Qualifier(w, 200) ] }, … ] }
  → compiler → [{ "actionType": "scale", "dimensions": { "width": 200 } }, { "actionType": "blur", "strength": 500 }]

4.3.1 Lexer#

The lexer turns the string into a flat token stream. Each token carries a type, its value and its position: { start, end }. The stream MUST end with exactly one EOF, including for the empty string. The table lists every token type with lexer output.

Token Emitted for Example
IDENTIFIER a run of letters, digits, %, @, # and embedded spaces w_200 → IDENTIFIER(w); l_text:Arial_20:a b → IDENTIFIER(a b)
NUMBER an integer or decimal, optionally - prefixed, possibly ending in a bare . before a separator a_-20 → NUMBER(-20); a_2.5 → NUMBER(2.5); w_1. → NUMBER(1.)
UNDERSCORE _ w_200 → UNDERSCORE(_)
COLON : e_blur:500 → COLON(:)
SEMICOLON ; br_av:video_(value_2m);audio_(value_128k)
COMMA , w_100,h_200
SLASH / w_100/h_200
DOT . not inside a number a_vflip.20 → DOT(.)
MINUS - not followed by a digit l_text:Arial_20:a-b → MINUS(-)
PLUS + l_text:Arial_20:a+b
ASTERISK * l_text:Arial_20:a*b
EQUALS = l_fetch:aHR0cHM6== → EQUALS(=) EQUALS(=)
QUOTE ' l_text:Arial_20:it's → IDENTIFIER(it) QUOTE(') IDENTIFIER(s)
DQUOTE " l_text:Arial_20:say "hi"
LPAREN, RPAREN (, ) br_av:video_(value_2m)
STRING text between a pair of !, delimiters stripped $label_!sale! → STRING(sale); !! → STRING()
VARIABLE $ and a name, sigil stripped $w_400 → VARIABLE(w)
WHITESPACE spaces not absorbed into an identifier l_text:Arial_20:a - b → IDENTIFIER(a) WHITESPACE( ) MINUS(-) WHITESPACE( ) IDENTIFIER(b)
EOF the end of input, always once "" → EOF()

Declared but never emitted. Three token types exist in the lexer's type and are never produced, and an implementation MUST NOT depend on them: UNIT (w_2px is NUMBER(2) IDENTIFIER(px)), PERCENT_ENCODED (prompt_a%20b is one IDENTIFIER(a%20b)) and EXCLAMATION (a ! always opens a string: a!b!c is IDENTIFIER(a) STRING(b) IDENTIFIER(c)).

Newlines produce no token: "w_200\n/h_100" lexes as w_200/h_100.

Lexical errors carry a position and the message below:

Input Error
!unterminated Unterminated string
$ Invalid variable name
w_1.x Invalid number format
w_& Unexpected character '&'

4.3.2 Abstract Syntax Tree#

The parser consumes the token stream and builds a tree of seven node types. Every node carries type and position; the table lists its other fields.

Node type Fields Produced by
Transformation Transformation segments: Segment[] the root of every parse
Segment Segment body: (Qualifier | VariableAssignment | Layer | LayerApply)[] each /-delimited component
Qualifier Qualifier name, prefix?, value?: RawValue w_200, e_blur:500, c_crop
VariableAssignment VariableAssignment name, prefix?, value: RawValue $w_400, $n_ctx:!k!
Layer Layer qualifier: Qualifier, layerSegment: Segment, transformation: Transformation l_… / u_… up to its fl_layer_apply
LayerApply LayerApply value: "fl_layer_apply" fl_layer_apply
RawValue RawValue value: string the value of any qualifier or assignment

The tree MUST preserve the order of the string: Transformation.segments holds the components in the order they are written, and Segment.body holds a component's qualifiers in the order they are written, with nothing dropped, merged or sorted. The compiler's rules depend on it — which qualifier wins when one repeats, and which component an action belongs to (Section 4.3.3) — and so does the position-sensitive reading of a layer's qualifiers. e_blur,e_sepia/e_grayscale MUST therefore parse to two segments, the first holding e_blur then e_sepia, before any rule about repeats is applied.

The parser does not interpret values: every value is a RawValue holding the text between the first _ and the next , or /. Interpreting it is the compiler's job, which is what lets a new action be supported without touching the grammar.

e_blur:500

{ "type": "Qualifier", "name": "e", "prefix": "blur", "value": { "type": "RawValue", "value": "500" } }

c_crop — no value

{ "type": "Qualifier", "name": "c", "prefix": "crop" }

bo_5px_solid_red — one qualifier; bo takes no prefix, so the whole tail is the value

{ "type": "Qualifier", "name": "bo", "value": { "type": "RawValue", "value": "5px_solid_red" } }

$n_ctx:!k! — the parser restores the ! delimiters the lexer stripped

{ "type": "VariableAssignment", "name": "n", "prefix": "ctx", "value": { "type": "RawValue", "value": "!k!" } }

co_rgb:fff,l_text:Arial_20:Hi/fl_layer_apply — a layer: layerSegment is the whole component the l_ qualifier sits in, because position carries meaning (co_ beside l_text colours the text; in its own component it would be a separate action)

{
  "type": "Layer",
  "qualifier": { "type": "Qualifier", "name": "l", "prefix": "text", "value": { "type": "RawValue", "value": "Arial_20:Hi" } },
  "layerSegment": {
    "type": "Segment",
    "body": [
      { "type": "Qualifier", "name": "co", "value": { "type": "RawValue", "value": "rgb:fff" } },
      { "type": "Qualifier", "name": "l", "prefix": "text", "value": { "type": "RawValue", "value": "Arial_20:Hi" } }
    ]
  },
  "transformation": {
    "type": "Transformation",
    "segments": [
      { "type": "Segment", "body": [{ "type": "Qualifier", "name": "co", "value": { "type": "RawValue", "value": "rgb:fff" } }] },
      { "type": "Segment", "body": [{ "type": "LayerApply", "value": "fl_layer_apply" }] }
    ]
  }
}

4.3.3 Compiler#

The compiler turns each segment into actions. Its rules, each with the serializer's output:

Segments compile independently and their actions are concatenated in order.

e_blur,e_sepia/e_grayscale   →   [{ "actionType": "sepia" }, { "actionType": "grayscale" }]

Within a segment, resize comes first. A c_ qualifier and the dimension, gravity and offset qualifiers it consumes (w, h, ar, g, x, y, z, b) compile into one resize action, placed before the segment's other actions whatever the written order.

e_sepia,c_scale,w_100                        →   [{ "actionType": "scale", "dimensions": { "width": 100 } }, { "actionType": "sepia" }]
c_pad,w_200,b_blue                           →   [{ "actionType": "pad", "dimensions": { "width": 200 }, "background": { "backgroundType": "color", "color": "blue" } }]
c_crop,w_200,h_100,g_auto:face,x_10,y_20     →   [{ "actionType": "crop", "dimensions": { "width": 200, "height": 100 }, "gravity": { "gravityType": "auto", "autoFocus": [{ "object": "face" }] }, "x": 10, "y": 20 }]
w_200                                        →   [{ "actionType": "scale", "dimensions": { "width": 200 } }]

The last qualifier of a kind wins.

e_blur:500,e_sepia:80   →   [{ "actionType": "sepia", "level": 80 }]
w_100,w_200             →   [{ "actionType": "scale", "dimensions": { "width": 200 } }]

No defaults. A property is present only when its qualifier is. A value the action compiler cannot read is omitted rather than invented:

e_blur:invalid   →   [{ "actionType": "blur" }]

Unrecognised text is kept. A qualifier no compiler claims becomes a custom action, after the segment's recognised actions:

unknown_qualifier,w_200   →   [{ "actionType": "scale", "dimensions": { "width": 200 } }, { "actionType": "custom", "transformationString": "unknown_qualifier" }]

Layers compile into overlay / underlay actions. The layer's own components become the source's transformation; qualifiers in the layer segment describe the source; qualifiers beside fl_layer_apply describe the placement.

l_logo/e_blur:500/fl_layer_apply              →   [{ "actionType": "overlay", "source": { "sourceType": "image", "publicId": "logo", "transformation": [{ "actionType": "blur", "strength": 500 }] } }]
l_logo/fl_layer_apply,g_north,x_10,y_20       →   [{ "actionType": "overlay", "source": { "sourceType": "image", "publicId": "logo" }, "position": { "gravity": { "gravityType": "direction", "compass": "north" }, "offsetX": 10, "offsetY": 20 } }]
b_blue,co_white,l_text:Arial_100:Hello/fl_layer_apply
                                              →   [{ "actionType": "overlay", "source": { "sourceType": "text", "text": "Hello", "textStyle": { "fontFamily": "Arial", "fontSize": 100 }, "backgroundColor": "blue", "textColor": "white" } }]

Position decides meaning for a text layer: c_fit in the layer segment is textFit (wrap the text to a width), while c_fit in its own component is a resize of the rendered text.

c_fit,w_400,l_text:Arial_20:Hello/fl_layer_apply   →   source: { …, "textFit": { "width": 400 } }
l_text:Arial_20:Hello/c_fit,w_400/fl_layer_apply   →   source: { …, "transformation": [{ "actionType": "fit", "dimensions": { "width": 400 } }] }

5. Security Considerations#

A transformation string is data, and the serializer treats it as such: decoding and encoding are pure functions of their input, evaluate nothing, and reach nothing. The language itself has no escape from that: an expression (Section 3.5) refers only to asset properties, variables and literals, and a condition (Section 3.6) only decides which components apply.

Two consequences for anyone handling models:

  • Unrecognised text passes through unchanged as a custom action (Section 3.7). A model produced from an untrusted string therefore carries that text verbatim, and MUST be treated as untrusted when it is encoded back into a URL.
  • Validation against the schema (Section 2) establishes that a model is well-formed, not that it is safe or cheap to deliver. Delivery applies its own limits, and a well-formed model can still describe a transformation delivery refuses.

6. References#

Normative

Informative

Appendix A. Collected Grammar#

The string, in ABNF, with rules named after the tokens (Section 3.1) and nodes (Section 3.2) that produce them:

Transformation     = Segment *( SLASH Segment )
Segment            = SegmentBody *( COMMA SegmentBody )
SegmentBody        = Qualifier / VariableAssignment / Layer / LayerApply
Qualifier          = IDENTIFIER UNDERSCORE RawValue
                   / IDENTIFIER UNDERSCORE IDENTIFIER [ COLON RawValue ]
VariableAssignment = VARIABLE UNDERSCORE RawValue
LayerApply         = "fl_layer_apply"
Layer              = Qualifier Segment *( SLASH Segment ) LayerApply
RawValue           = 1*token

IDENTIFIER = 1*( ALPHA / DIGIT / "%" / "@" / "#" / SP )
NUMBER     = [ "-" ] 1*DIGIT [ "." *DIGIT ]         ; a bare trailing "." only before , / _ : or the end
STRING     = "!" *( %x00-20 / %x22-7F ) "!"
VARIABLE   = "$" 1*( ALPHA / DIGIT / "%" / "@" / "#" )

The expression types and the URL form each is written as, from Sections 3.5 and 3.6:

Type Written as Children
OrExpression <left>_or_<right> left: BooleanExpression · right: AndExpression | ComparisonExpression | VariableCheckExpression
AndExpression <left>_and_<right> left: AndExpression | ComparisonExpression | VariableCheckExpression · right: ComparisonExpression | VariableCheckExpression
ComparisonExpression <left>_<eq|ne|lt|gt|lte|gte|in|nin>_<right> left, right: ComparisonOperand
VariableCheckExpression isdef_$name · isndef_$name variable: VariableReference
SumExpression <left>_<add|sub>_<right> left: ArithmeticExpression | NumericExpression · right: ProductExpression | NumericExpression
ProductExpression <left>_<mul|div|mod|pow>_<right> left: ProductExpression | NumericExpression · right: NumericExpression
NumericExpression any of its members NumericValue | PropertyValue | VariableReference | ConversionExpression
ConversionExpression <value>_to_i · <value>_to_f value: VariableReference | ContextReference | MetadataReference
NumericValue · PropertyValue · StringValue · CollectionPropertyValue 2 · iw · !text! · tags —
VariableReference · ContextReference · MetadataReference · AssetReference $w · ctx:!key! · md:!id! · ref:!publicId! —
VariableAssignment $name_<value> value: NumericExpression | ArithmeticExpression | StringValue | ContextReference | MetadataReference | AssetReference

Precedence, tightest first: mul div mod pow → add sub → comparison → and → or. Every level associates left to right; comparison does not associate. A right child is always a tighter level than its parent, and only a left child may repeat its parent's level — that is how the types above encode the precedence and the associativity.

Appendix B. Property Codes#

Model name URL Model name URL
width · height w · h page_count · current_page pc · cp
initial_width · initial_height iw · ih face_count fc
aspect_ratio · initial_aspect_ratio ar · iar custom_coordinates_count · region_count ccc · rc
trimmed_aspect_ratio tar illustration_score · initial_density ils · idn
page_x · page_y px · py duration · initial_duration du · idu

Collections, for in and nin: tags → tags, region_names → rn, layer_names → pgnames, context → ctx, structured_metadata → md.