Skip to content
Draft
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
32 changes: 26 additions & 6 deletions packages/render/src/primitives/renderBackground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import parseColor from '../utils/parseColor';
import { Context } from '../types';
import { SafeNode } from '@react-pdf/layout';

// Small bleed to prevent anti-aliasing artifacts between adjacent elements.
// PDF viewers often render a thin hairline gap between shapes that share
// an exact edge. Expanding the fill slightly creates overlap that masks this.
// 0.5pt ≈ 0.18mm — invisible to the eye but enough to cover the artifact.
const BLEED = 0.5;

const hasBorderRadius = (node: SafeNode) => {
const s = node.style;
return (
((s?.borderTopLeftRadius as number) || 0) > 0 ||
((s?.borderTopRightRadius as number) || 0) > 0 ||
((s?.borderBottomRightRadius as number) || 0) > 0 ||
((s?.borderBottomLeftRadius as number) || 0) > 0
);
};

const drawBackground = (ctx: Context, node: SafeNode) => {
if (!node.box) return;

Expand All @@ -13,19 +29,23 @@ const drawBackground = (ctx: Context, node: SafeNode) => {
const nodeOpacity = isNil(node.style?.opacity) ? 1 : node.style.opacity;
const opacity = Math.min(color.opacity, nodeOpacity);

ctx
.fillOpacity(opacity)
.fillColor(color.value)
.rect(left, top, width, height)
.fill();
ctx.fillOpacity(opacity).fillColor(color.value);

if (hasBorderRadius(node)) {
ctx.rect(left, top, width, height).fill();
} else {
ctx
.rect(left - BLEED, top - BLEED, width + 2 * BLEED, height + 2 * BLEED)
.fill();
}
};

const renderBackground = (ctx: Context, node: SafeNode) => {
const hasBackground = !!node.box && !!node.style?.backgroundColor;

if (hasBackground) {
ctx.save();
clipNode(ctx, node);
if (hasBorderRadius(node)) clipNode(ctx, node);
drawBackground(ctx, node);
ctx.restore();
}
Expand Down
14 changes: 13 additions & 1 deletion packages/render/tests/primitives/renderBackground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,22 @@ describe('primitive renderBackground', () => {
renderBackground(ctx, node);

expect(ctx.fillColor.mock.calls).toEqual([['#FF0000']]);
expect(ctx.rect.mock.calls).toEqual([[40, 20, 140, 200]]);
expect(ctx.rect.mock.calls).toEqual([[39.5, 19.5, 141, 201]]);
expect(ctx.fill.mock.calls).toEqual([[]]);
});

test('should not apply bleed when border radius is present', () => {
const ctx = createCTX();
const box = { top: 20, left: 40, width: 140, height: 200 } as Box;
const style = { backgroundColor: 'red', borderTopLeftRadius: 5 };
const node: SafeNode = { type: P.View, style, props: {}, box };

renderBackground(ctx, node);

expect(ctx.rect.mock.calls).toEqual([[40, 20, 140, 200]]);
expect(ctx.clip.mock.calls).toHaveLength(1);
});

test('should be scoped operation', () => {
const ctx = createCTX();
const box = { top: 20, left: 40, width: 140, height: 200 } as Box;
Expand Down
Loading