From 7ddc9d78b87a556282a1901ed80de01907d99f93 Mon Sep 17 00:00:00 2001 From: Diego Muracciole Date: Tue, 7 Apr 2026 19:18:06 +0200 Subject: [PATCH] fix(render): border artifacts --- .../render/src/primitives/renderBackground.ts | 32 +++++++++++++++---- .../tests/primitives/renderBackground.test.ts | 14 +++++++- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/render/src/primitives/renderBackground.ts b/packages/render/src/primitives/renderBackground.ts index 783da6c1c..c09cc3e4c 100644 --- a/packages/render/src/primitives/renderBackground.ts +++ b/packages/render/src/primitives/renderBackground.ts @@ -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; @@ -13,11 +29,15 @@ 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) => { @@ -25,7 +45,7 @@ const renderBackground = (ctx: Context, node: SafeNode) => { if (hasBackground) { ctx.save(); - clipNode(ctx, node); + if (hasBorderRadius(node)) clipNode(ctx, node); drawBackground(ctx, node); ctx.restore(); } diff --git a/packages/render/tests/primitives/renderBackground.test.ts b/packages/render/tests/primitives/renderBackground.test.ts index b827cfb15..c6d5189fc 100644 --- a/packages/render/tests/primitives/renderBackground.test.ts +++ b/packages/render/tests/primitives/renderBackground.test.ts @@ -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;