ColorMatrix Struct
The same shape as SVG’s feColorMatrix type="matrix" (SVG Filter Effects §15.17) and CSS
Filter Effects’ color-altering filter functions (grayscale(), sepia(),
saturate(), hue-rotate(), invert(), brightness(), contrast()):
an affine map [R' G' B' A'] = Linear · [R G B A] + Offset over premultiplied-free RGBA
components in [0, 1].
public readonly struct ColorMatrix
Why this is a math-only type with no PDF-writing method on it. The obvious next step -
“apply this to whatever a Form XObject tile already painted” - does not have a general,
spec-conformant PDF answer, and that finding shapes how a PDF Type 4 function
and Canvas.DrawImageWithColorMatrix are actually built. Two real PDF mechanisms exist
that are shaped like “run every color through a function”, and neither does what a first read of
“just make a Type 4 function and hang it off the graphics state” suggests:
- ExtGState /TR (transfer function, ISO 32000-1 §8.6.5.3). The spec is explicit that
this is a per-component, not per-color, operation: “a transfer function … adjusts the values of
a single colour component” and, when one function is given, “that function is applied to all
process colorants” (each colorant separately - not to a colorant knowing the values of the
other colorants). It has no way to see R while computing G. So
/TR can reproduce any
ColorMatrix whose Linear has no off-diagonal R/G/B coupling - CSS
brightness(), contrast(), invert() are all genuinely diagonal (each output
channel is a function of only that same input channel) and map exactly onto one or three
independent /TR functions. grayscale(), sepia(), saturate() and
hue-rotate() are not diagonal (each output channel mixes all three inputs) and cannot be
expressed as a PDF transfer function at all, full stop - not “awkwardly”, not “with a workaround”,
genuinely not representable by the construct the spec defines. IsChannelIndependent
is the exact test for which side of this line a given matrix falls on.
- A DeviceN/Separation colour space’s tint-transform function (ISO 32000-1 §8.6.6.2, §7.10).
This genuinely can do arbitrary cross-channel mixing - a DeviceN tint-transform is exactly “an
n-in, m-out function applied to every sample” - but it is a mechanism for specifying what a
colour value or an image’s raw sample data means, not for re-processing something already
painted. It can turn a raster image’s stored (r,g,b,a) sample bytes into displayed colour through
an arbitrary ColorMatrix-shaped function (a legitimate use of
a PDF Type 4 function, wiring it in as
an image’s colour space rather than a graphics-state transfer function). It cannot retroactively
recolor an already-composited transparency group’s result the way an ExtGState parameter does -
there is no PDF graphics-state entry that takes “the RGBA this group already produced” as input.
Doing that for arbitrary vector content (paths, text, gradients, patterns already burned into a
Form XObject’s content stream via
rg/RG operators) would require either rewriting
every paint operator in the subtree to go through a shared DeviceN colour space (an invasive,
whole-subtree change, not a compositing-time one) or rasterizing the tile to a bitmap and applying
the matrix per pixel in software - which is exactly what browsers actually do when printing a
CSS-filtered, non-channel-independent subtree to PDF. This codebase’s tiles
(Canvas.CreateTile) are deliberately never rasterized, so that path is out of scope for
this foundational change and is left to whichever later phase adds a rasterization capability.
Net effect: DrawImageWithColorMatrix is spec-correct and real for the channel-independent
subset (via /TR), and throws rather than silently mis-rendering for the cross-channel
subset, instead of emitting a plausible-looking but non-conformant PDF construct.
| Fields |
|
| Identity |
The identity color matrix: output equals input, unchanged. |
| Properties |
|
| IsChannelIndependent |
Whether every output channel among R, G, B depends on only its own matching input channel (i.e. Linear has no off-diagonal coupling among the R/G/B/A inputs feeding the R/G/B outputs) - the exact condition under which this matrix is representable as a PDF ExtGState /TR transfer function (see the type-level remarks). Input alpha’s contribution to each of the R/G/B outputs is included in the check: a filter whose R/G/B output depends on input alpha could not be expressed as a transfer function either, since /TR only ever sees one color component’s own value, never alpha. The matrix’s own alpha OUTPUT (column 4 - System.Numerics.Matrix4x4.M14/System.Numerics.Matrix4x4.M24/ System.Numerics.Matrix4x4.M34/System.Numerics.Matrix4x4.M44) is not checked - /TR is a color-space transfer function and is never applied to alpha, so nothing about how this matrix would compute alpha bears on whether it fits. See Linear’s remarks for why the row is the input component and the column is the output component here. |
| Linear |
The linear (4x4) part of the affine map. Apply(Vector4) and Compose(ColorMatrix) both go through System.Numerics.Vector4.Transform(System.Numerics.Vector4,System.Numerics.Matrix4x4), which treats a color as a row vector (result = color * Linear): per its documented implementation, output component c is sum over r of color[r] * Linear[r, c]. That makes each field’s ROW (the first index in Mrowcol) the INPUT component and its COLUMN the OUTPUT component - e.g. Linear.M21 (row 2 = input G, col 1 = output R) is how much input G contributes to output R. This is the transpose of feColorMatrix’s own row-major table (SVG Filter Effects §15.17, where row = output); a caller building a ColorMatrix from such a table must transpose it first. |
| Offset |
The constant term added after Linear is applied, one component per output channel (R, G, B, A). |
| Methods |
|
| Apply(Vector4) |
Applies this matrix to a single RGBA color: Linear * color + Offset. Used by tests and by any future caller that needs the transformed value of one already-known, static color (e.g. a solid text/fill color) - not by DrawImageWithColorMatrix, which composites against arbitrary already-painted tile content rather than a single known input color. |
| Compose(ColorMatrix) |
Composes this matrix with appliedAfterThis, producing the single matrix equivalent to applying this first and then appliedAfterThis to the result - i.e. result.Apply(c) == appliedAfterThis.Apply(this.Apply(c)) for every color c. Affine composition: the linear parts multiply (order matters - the second transform’s matrix goes on the left), and the first transform’s offset is carried through the second transform’s linear part before the second transform’s own offset is added. |