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
6 changes: 6 additions & 0 deletions .changeset/strong-ligers-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-pdf/stylesheet": minor
"@react-pdf/render": minor
---

Support hsla and hwba color models
11 changes: 0 additions & 11 deletions packages/render/globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
declare module 'abs-svg-path';

declare module 'color-string' {
function get(hex: string): { model: string; value: number[] } | null;

function toHex(color: number[]): string;

export default {
get,
to: { hex: toHex },
};
}

declare module 'normalize-svg-path' {
export default function normalizePath(path: any[]): any[];
}
Expand Down
3 changes: 1 addition & 2 deletions packages/render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,14 @@
"@react-pdf/textkit": "^6.1.0",
"@react-pdf/types": "^2.9.2",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"color-string": "^2.1.4",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
},
"devDependencies": {
"@react-pdf/layout": "^4.4.2",
"@types/abs-svg-path": "^0.1.3",
"@types/color-string": "^1.5.5",
"@types/pdfkit": "^0.13.9"
},
"files": [
Expand Down
63 changes: 62 additions & 1 deletion packages/render/src/utils/parseColor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,56 @@ import colorString from 'color-string';

const black = { value: '#000', opacity: 1 };

const hslToRgb = (
h: number,
s: number,
l: number,
): [number, number, number] => {
const sNorm = s / 100;
const lNorm = l / 100;

const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lNorm - c / 2;

let r: number, g: number, b: number;

if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];

return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
};

const hwbToRgb = (
h: number,
w: number,
bl: number,
): [number, number, number] => {
const wNorm = w / 100;
const blNorm = bl / 100;

if (wNorm + blNorm >= 1) {
const gray = Math.round((wNorm / (wNorm + blNorm)) * 255);
return [gray, gray, gray];
}

const [r, g, b] = hslToRgb(h, 100, 50);

return [
Math.round((r / 255) * (1 - wNorm - blNorm) * 255 + wNorm * 255),
Math.round((g / 255) * (1 - wNorm - blNorm) * 255 + wNorm * 255),
Math.round((b / 255) * (1 - wNorm - blNorm) * 255 + wNorm * 255),
];
};

// TODO: parse to number[] in layout to avoid this step
const parseColor = (hex?: string) => {
if (!hex) return black;
Expand All @@ -10,8 +60,19 @@ const parseColor = (hex?: string) => {

if (!parsed) return black;

const value = colorString.to.hex(parsed.value.slice(0, 3));
let r: number, g: number, b: number;

if (parsed.model === 'hsl') {
[r, g, b] = hslToRgb(parsed.value[0], parsed.value[1], parsed.value[2]);
} else if (parsed.model === 'hwb') {
[r, g, b] = hwbToRgb(parsed.value[0], parsed.value[1], parsed.value[2]);
} else {
[r, g, b] = parsed.value;
}

const value = colorString.to.hex(r, g, b);
const opacity = parsed.value[3];
if (!value) return black;

return { value, opacity };
};
Expand Down
58 changes: 58 additions & 0 deletions packages/render/tests/primitives/renderBackground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,62 @@ describe('primitive renderBackground', () => {

expect(ctx.fillOpacity.mock.calls).toEqual([[0]]);
});

test('should apply RGBA color opacity correctly', () => {
const ctx = createCTX();
const box = { top: 20, left: 40, width: 140, height: 200 } as Box;
const style = { backgroundColor: 'rgba(255, 0, 0, 0.5)' };
const node: SafeNode = { type: P.View, style, props: {}, box };

renderBackground(ctx, node);

expect(ctx.fillColor.mock.calls).toEqual([['#FF0000']]);
expect(ctx.fillOpacity.mock.calls).toEqual([[0.5]]);
});

test('should apply HSLA color opacity correctly', () => {
const ctx = createCTX();
const box = { top: 20, left: 40, width: 140, height: 200 } as Box;
const style = { backgroundColor: 'hsla(120, 100%, 50%, 0.6)' };
const node: SafeNode = { type: P.View, style, props: {}, box };

renderBackground(ctx, node);

expect(ctx.fillColor.mock.calls).toEqual([['#00FF00']]);
expect(ctx.fillOpacity.mock.calls).toEqual([[0.6]]);
});

test('should apply 8-digit hex color opacity correctly', () => {
const ctx = createCTX();
const box = { top: 20, left: 40, width: 140, height: 200 } as Box;
const style = { backgroundColor: '#FF000080' };
const node: SafeNode = { type: P.View, style, props: {}, box };

renderBackground(ctx, node);

expect(ctx.fillColor.mock.calls).toEqual([['#FF0000']]);
expect(ctx.fillOpacity.mock.calls[0][0]).toBeCloseTo(0.502, 2);
});

test('should use node opacity when color opacity is larger', () => {
const ctx = createCTX();
const box = { top: 20, left: 40, width: 140, height: 200 } as Box;
const style = { backgroundColor: 'rgba(255, 0, 0, 0.8)', opacity: 0.5 };
const node: SafeNode = { type: P.View, style, props: {}, box };

renderBackground(ctx, node);

expect(ctx.fillOpacity.mock.calls).toEqual([[0.5]]);
});

test('should use color opacity when it is smaller than node opacity', () => {
const ctx = createCTX();
const box = { top: 20, left: 40, width: 140, height: 200 } as Box;
const style = { backgroundColor: 'rgba(255, 0, 0, 0.3)', opacity: 0.9 };
const node: SafeNode = { type: P.View, style, props: {}, box };

renderBackground(ctx, node);

expect(ctx.fillOpacity.mock.calls).toEqual([[0.3]]);
});
});
18 changes: 18 additions & 0 deletions packages/render/tests/utils/parseColor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,22 @@ describe('parse color util', () => {
const color = parseColor('#FF00FF54');
expect(color.opacity).toBe(0.32941176470588235);
});

test('should parse HSL color', () => {
const color = parseColor('hsl(0, 100%, 50%)');
expect(color.value).toBe('#FF0000');
expect(color.opacity).toBe(1);
});

test('should parse HSLA color', () => {
const color = parseColor('hsla(120, 100%, 50%, 0.6)');
expect(color.value).toBe('#00FF00');
expect(color.opacity).toBe(0.6);
});

test('should parse HWB color', () => {
const color = parseColor('hwb(60, 3%, 60%)');
expect(color.value).toBe('#666608');
expect(color.opacity).toBe(1);
});
});
2 changes: 1 addition & 1 deletion packages/stylesheet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/types": "^2.9.2",
"color-string": "^1.9.1",
"color-string": "^2.1.4",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/stylesheet/src/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const isHsl = (value: string) => /^hsla?\(/i.test(value);
const parseRgb = (value: string) => {
const rgb = colorString.get.rgb(value);
if (!rgb) return value;
return colorString.to.hex(rgb);
return colorString.to.hex(rgb[0], rgb[1], rgb[2], rgb[3]);
};

/**
Expand Down
76 changes: 47 additions & 29 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,14 @@
dependencies:
"@babel/helper-plugin-utils" "^7.18.6"

"@babel/plugin-transform-react-jsx-development@^7.18.6", "@babel/plugin-transform-react-jsx-self@^7.23.3":
name "@babel/plugin-transform-react-jsx-development"
"@babel/plugin-transform-react-jsx-development@^7.18.6":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.23.3.tgz#ed3e7dadde046cce761a8e3cf003a13d1a7972d9"
integrity sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==
dependencies:
"@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-react-jsx-self@^7.23.3":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.23.3.tgz#ed3e7dadde046cce761a8e3cf003a13d1a7972d9"
integrity sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==
Expand Down Expand Up @@ -2545,11 +2551,6 @@
dependencies:
"@babel/types" "^7.3.0"

"@types/color-string@^1.5.5":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/color-string/-/color-string-1.5.5.tgz#db38c62473c76eda9696ffb3c093b9dc8e9dceea"
integrity sha512-p9+C1ssJsjnHV8nn96rkimm2h90LclLIwgBfiMCHW0oUr6jLmB+wzZUEGJPduB/D2RzI2Ahoe69xKNOawX6jgw==

"@types/eslint-scope@^3.7.7":
version "3.7.7"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5"
Expand Down Expand Up @@ -3968,18 +3969,22 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=

color-name@^1.0.0, color-name@~1.1.4:
color-name@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693"
integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==

color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==

color-string@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
color-string@^2.1.4:
version "2.1.4"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.4.tgz#9dcf566ff976e23368c8bd673f5c35103ab41058"
integrity sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color-name "^2.0.0"

color-support@^1.1.3:
version "1.1.3"
Expand Down Expand Up @@ -6233,11 +6238,6 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=

is-arrayish@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==

is-async-function@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523"
Expand Down Expand Up @@ -9799,13 +9799,6 @@ simple-get@^3.0.3:
once "^1.3.1"
simple-concat "^1.0.0"

simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
dependencies:
is-arrayish "^0.3.1"

size-limit@11.0.1, size-limit@^11.0.1:
version "11.0.1"
resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-11.0.1.tgz#e34ab3302b83833843d578e70a2bf3c6da29f123"
Expand Down Expand Up @@ -10031,7 +10024,7 @@ string-argv@0.3.1:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==

"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand All @@ -10049,6 +10042,15 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0"

"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
Expand Down Expand Up @@ -10167,7 +10169,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand All @@ -10181,6 +10183,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1:
dependencies:
ansi-regex "^2.0.0"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
Expand Down Expand Up @@ -11220,7 +11229,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -11238,6 +11247,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down