Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions @types/culori/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module "culori" {
export function converter(mode: string): (color: unknown) => unknown;
export function parse(input: string): unknown;
export function formatCss(color: unknown): string | undefined;
}
1 change: 1 addition & 0 deletions packages/css-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"change-case": "^5.4.4",
"colord": "^2.9.3",
"css-tree": "^3.1.0",
"culori": "^4.0.2",
"openai": "^3.2.1",
"p-retry": "^6.2.1",
"warn-once": "^0.1.1"
Expand Down
127 changes: 127 additions & 0 deletions packages/css-data/src/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { converter, formatCss, parse } from "culori";
import type { RgbValue } from "@webstudio-is/css-engine";

const toRgb = converter("rgb");

export type ParsedColor = RgbValue & {
colorSpace?: string;
original?: string;
};

const clamp01 = (value: number) => Math.min(1, Math.max(0, value));

const toByte = (value: number) => Math.round(clamp01(value) * 255);

/**
* Parse arbitrary CSS color strings (CSS Color 4/5) into an RGB representation.
* Keeps the source color space and a normalized string so we can round-trip.
*/
export const parseCssColor = (input: string): ParsedColor | undefined => {
const normalizedInput = input.trim();
if (normalizedInput.length === 0) {
return;
}

let parsed: unknown;
try {
parsed = parse(normalizedInput);
} catch {
return;
}

if (parsed === undefined || parsed === null) {
return;
}

const rgbResult = toRgb(parsed);
if (
rgbResult === undefined ||
rgbResult === null ||
typeof rgbResult !== "object"
) {
return;
}

const {
r,
g,
b,
alpha: alphaValue,
} = rgbResult as {
r?: number;
g?: number;
b?: number;
alpha?: number;
};

if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") {
return;
}

const alpha = Number(clamp01(alphaValue ?? 1).toFixed(2));
const formatted = typeof parsed === "object" ? formatCss(parsed) : undefined;

const color: ParsedColor = {
type: "rgb",
r: toByte(r),
g: toByte(g),
b: toByte(b),
alpha,
};

const mode =
typeof parsed === "object" && parsed !== null && "mode" in parsed
? (parsed as { mode?: string }).mode
: undefined;

if (mode && mode !== "rgb") {
color.colorSpace = mode;
}

if (mode && mode !== "rgb") {
color.original = formatted ?? normalizedInput;
}

return color;
};

/**
* Serialize an RGB color, optionally preserving the source color space when provided.
*/
export const formatRgbColor = (
color: ParsedColor,
preferredColorSpace?: string
): string => {
const targetSpace = preferredColorSpace ?? color.colorSpace;
if (color.original && preferredColorSpace === color.colorSpace) {
return color.original;
}

const rgbColor = {
mode: "rgb",
r: clamp01(color.r / 255),
g: clamp01(color.g / 255),
b: clamp01(color.b / 255),
alpha: clamp01(color.alpha),
};

if (targetSpace) {
try {
const toPreferred = converter(targetSpace);
const converted = toPreferred(rgbColor);
const formattedPreferred = formatCss(converted);
if (formattedPreferred) {
return formattedPreferred;
}
} catch {
// If the color space is not supported, fall back to RGBA below.
}
}

const formatted = formatCss(rgbColor);
if (formatted) {
return formatted;
}

return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.alpha})`;
};
6 changes: 6 additions & 0 deletions packages/css-data/src/culori.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module "culori" {
// Minimal surface we use; culori ships JS only.
export function converter(mode: string): (color: unknown) => unknown;
export function parse(input: string): unknown;
export function formatCss(color: unknown): string | undefined;
}
1 change: 1 addition & 0 deletions packages/css-data/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from "./shorthands";
export { shorthandProperties } from "./__generated__/shorthand-properties";

export { properties as propertiesData } from "./__generated__/properties";
export * from "./color";
9 changes: 9 additions & 0 deletions packages/css-data/src/parse-css-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ describe("Parse CSS value", () => {
value: "red",
});
});

test("Supports OKLCH values", () => {
expect(parseCssValue("color", "oklch(0.7 0.12 45 / 0.8)")).toMatchObject({
type: "rgb",
alpha: 0.8,
colorSpace: "oklch",
original: "oklch(0.7 0.12 45 / 0.8)",
});
});
});
});

Expand Down
41 changes: 16 additions & 25 deletions packages/css-data/src/parse-css-value.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { colord, extend } from "colord";
import namesPlugin from "colord/plugins/names";
import {
type CssNode,
type FunctionNode,
Expand Down Expand Up @@ -29,9 +27,12 @@ import {
} from "@webstudio-is/css-engine";
import { keywordValues } from "./__generated__/keyword-values";
import { units } from "./__generated__/units";
import { parseCssColor } from "./color";

// To support color names
extend([namesPlugin]);
const isColorProperty = (property: CssProperty): boolean => {
const name = property as string;
return name.endsWith("color") || name === "fill" || name === "stroke";
};

export const cssTryParseValue = (input: string): undefined | CssNode => {
try {
Expand Down Expand Up @@ -64,6 +65,10 @@ export const isValidDeclaration = (
return true;
}

if (isColorProperty(property) && parseCssColor(value)) {
return true;
}

// these properties have poor support natively and in csstree
// though rendered styles are merged as shorthand
// so validate artifically
Expand Down Expand Up @@ -159,16 +164,9 @@ const tupleProps = new Set<CssProperty>([
const availableUnits = new Set<string>(Object.values(units).flat());

const parseColor = (colorString: string): undefined | RgbValue => {
const color = colord(colorString);
if (color.isValid()) {
const rgb = color.toRgb();
return {
type: "rgb",
alpha: rgb.a,
r: rgb.r,
g: rgb.g,
b: rgb.b,
};
const parsed = parseCssColor(colorString);
if (parsed) {
return parsed;
}
};

Expand Down Expand Up @@ -294,18 +292,11 @@ const parseLiteral = (
}
}
if (node?.type === "Function") {
// <color-function>
if (
node.name === "hsl" ||
node.name === "hsla" ||
node.name === "rgb" ||
node.name === "rgba"
) {
const color = parseColor(generate(node));
if (color) {
return color;
}
const color = parseColor(generate(node));
if (color) {
return color;
}

if (node.name === "var") {
return parseCssVar(node);
}
Expand Down
18 changes: 4 additions & 14 deletions packages/css-data/src/property-parsers/gradient-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@ import {
type VarValue,
toValue,
} from "@webstudio-is/css-engine";
import { colord, extend } from "colord";
import namesPlugin from "colord/plugins/names";
import type { GradientColorValue, GradientStop } from "./types";

extend([namesPlugin]);
import { parseCssColor } from "../color";

export const angleUnitIdentifiers = ["deg", "grad", "rad", "turn"] as const;
const angleUnitSet = new Set<string>(angleUnitIdentifiers);
Expand Down Expand Up @@ -119,16 +116,9 @@ export const getColor = (
return parsed;
}
if (parsed.type === "keyword") {
const color = colord(parsed.value);
if (color.isValid()) {
const { r, g, b, a } = color.toRgb();
return {
type: "rgb",
r,
g,
b,
alpha: a,
} satisfies GradientColorValue;
const color = parseCssColor(parsed.value);
if (color) {
return color as GradientColorValue;
}
}
};
Expand Down
12 changes: 12 additions & 0 deletions packages/css-engine/src/core/to-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ describe("Convert WS CSS Values to native CSS strings", () => {
expect(value).toBe("");
});

test("rgb preserves original string when provided", () => {
const value = toValue({
type: "rgb",
r: 10,
g: 20,
b: 30,
alpha: 0.5,
original: "oklch(0.5 0.05 90 / 0.5)",
});
expect(value).toBe("oklch(0.5 0.05 90 / 0.5)");
});

test("var", () => {
const value = toValue({ type: "var", value: "namespace" });
expect(value).toBe("var(--namespace)");
Expand Down
3 changes: 3 additions & 0 deletions packages/css-engine/src/core/to-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export const toValue = (
}

if (value.type === "rgb") {
if (value.original) {
return value.original;
}
return `rgba(${value.r}, ${value.g}, ${value.b}, ${value.alpha})`;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/css-engine/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ const RgbValue = z.object({
g: z.number(),
b: z.number(),
alpha: z.number(),
// Optional color space metadata used to preserve user intent when parsing CSS Color 4/5 formats.
colorSpace: z.string().optional(),
// Original CSS color string, used to round-trip non-rgb formats without losing fidelity.
original: z.string().optional(),
hidden: z.boolean().optional(),
});
export type RgbValue = z.infer<typeof RgbValue>;
Expand Down
Loading
Loading