From fc39796ca9803bd5a3d5896c9480cfa5e3360cbc Mon Sep 17 00:00:00 2001 From: Leslie Owusu-Appiah Date: Sun, 31 May 2026 00:06:07 +0200 Subject: [PATCH] fix(renderer): clip text to shape outline on circle / parallelogram cards (#53a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text on circle and parallelogram cards clipped only to the bounding rect, so long body content (and side labels) could spill past the curved or slanted edge into the card's empty corners. Clip to the actual shape path instead. The top-level `` mechanism (#167) already works for rects; this swaps the clip region to the shape's SVG path when the node is a circle / parallelogram, falling back to the bounding rect otherwise. Mirrored in the Picture path (`drawTextNode`) via `canvas.clipPath` so pinch-overlay output stays pixel-identical to the live tree (two-paths-in-sync rule). Extracts the shape geometry into a shared `nodes/shapes.ts` (parallelogram + oval SVG paths, `shapeClipPath` dispatcher). This is the single source of truth for the outline — previously the parallelogram maths was duplicated in SkiaCardRenderer and useCanvasPicture; both now delegate here, so a clip outline can never drift from the fill outline. Pure string in/out, no Skia/ESM deps, so it unit-tests cleanly (10 tests). Scope: shape clipping only (#53 item 1). The remaining #53 item — rotated labels on mixed-zone cards (label + body/header/footer) — is a separate renderer-extraction refactor, tracked as its own follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/renderer/nodes/SkiaCardRenderer.tsx | 12 +--- src/renderer/nodes/SkiaTextRenderer.tsx | 28 +++++--- src/renderer/nodes/__tests__/shapes.test.ts | 74 +++++++++++++++++++ src/renderer/nodes/shapes.ts | 78 +++++++++++++++++++++ src/renderer/useCanvasPicture.ts | 47 ++++++++----- 5 files changed, 202 insertions(+), 37 deletions(-) create mode 100644 src/renderer/nodes/__tests__/shapes.test.ts create mode 100644 src/renderer/nodes/shapes.ts diff --git a/src/renderer/nodes/SkiaCardRenderer.tsx b/src/renderer/nodes/SkiaCardRenderer.tsx index f77a479..765970b 100644 --- a/src/renderer/nodes/SkiaCardRenderer.tsx +++ b/src/renderer/nodes/SkiaCardRenderer.tsx @@ -7,13 +7,13 @@ import type {CanvasNode} from '../../core'; import type {EnrichedTextNode} from '../extensions/cssclasses'; import {getNodeColors, type ColorScheme} from '../theme'; import {devFlags} from '../devFlags'; +import {parallelogramPath} from './shapes'; interface Props { node: CanvasNode; colorScheme: ColorScheme; } -const PARALLELOGRAM_SKEW = 0.2; const DEG_TO_RAD = Math.PI / 180; /** @@ -85,15 +85,7 @@ function makeSideBorderPath(x: number, y: number, w: number, h: number, r: numbe } function makeParallelogramPath(x: number, y: number, w: number, h: number, direction: 'left' | 'right') { - const skew = w * PARALLELOGRAM_SKEW; - if (direction === 'left') { - return Skia.Path.MakeFromSVGString( - `M ${x + skew} ${y} L ${x + w} ${y} L ${x + w - skew} ${y + h} L ${x} ${y + h} Z` - ); - } - return Skia.Path.MakeFromSVGString( - `M ${x} ${y} L ${x + w - skew} ${y} L ${x + w} ${y + h} L ${x + skew} ${y + h} Z` - ); + return Skia.Path.MakeFromSVGString(parallelogramPath(x, y, w, h, direction)); } /** Compute gradient start/end points for a given angle within a rect. */ diff --git a/src/renderer/nodes/SkiaTextRenderer.tsx b/src/renderer/nodes/SkiaTextRenderer.tsx index 4767916..b7c9342 100644 --- a/src/renderer/nodes/SkiaTextRenderer.tsx +++ b/src/renderer/nodes/SkiaTextRenderer.tsx @@ -9,6 +9,7 @@ import {hasCallouts, parseCallouts, getHeader, getFooter, getLabels, getCentered import type {ColorScheme} from '../theme'; import {parseToSegments, toPlainText} from '../markdown'; import {buildParagraph, getParagraphColours} from '../paragraphBuilder'; +import {shapeClipPath} from './shapes'; const DEG_TO_RAD = Math.PI / 180; const ZONE_HEIGHT = 28; @@ -69,6 +70,16 @@ export function SkiaTextRenderer({node, colorScheme, offsetX, offsetY}: Props) { const rotateCard = enriched.renderProps?.rotateCard; const shape = enriched.renderProps?.shape; + // Clip text to the card outline so it can't bleed past the edge into + // neighbouring nodes (#167). For circle / parallelogram cards, clip to the + // actual shape path rather than the bounding rect, so text also respects the + // curved / slanted edges (#53). Falls back to the bounding rect for + // rectangular and unshaped cards. World coords — survives the rotation Group. + const clipX = node.x + offsetX; + const clipY = node.y + offsetY; + const shapeClip = shapeClipPath(shape, clipX, clipY, node.width, node.height); + const clipRegion = shapeClip ?? rect(clipX, clipY, node.width, node.height); + // Parse callout zones (header, footer, labels) from text content const {bodyText, callouts} = useMemo( () => hasCallouts(rawContent) ? parseCallouts(rawContent) : {bodyText: rawContent, callouts: []}, @@ -223,7 +234,7 @@ export function SkiaTextRenderer({node, colorScheme, offsetX, offsetY}: Props) { } return ( - + inside an - // element array (the parent's children list) is a no-op per #96. The clip - // is in world coords so it survives the rotation Group above it. Intra- - // card scroll is tracked separately at #168. + // Clip the rendered text to the card outline — long body content would + // otherwise bleed past the card edge into adjacent nodes (#167), and on + // circle / parallelogram cards past the curved / slanted edge (#53; see + // `clipRegion` above). Top-level-return placement is load-bearing: + // inside an element array (the parent's children list) is a + // no-op per #96. The clip is in world coords so it survives the rotation + // Group above it. Intra-card scroll is tracked separately at #168. return ( - + {content} ); diff --git a/src/renderer/nodes/__tests__/shapes.test.ts b/src/renderer/nodes/__tests__/shapes.test.ts new file mode 100644 index 0000000..2a1fe4a --- /dev/null +++ b/src/renderer/nodes/__tests__/shapes.test.ts @@ -0,0 +1,74 @@ +import { + parallelogramPath, + ovalPath, + shapeClipPath, + PARALLELOGRAM_SKEW, +} from '../shapes'; + +describe('parallelogramPath', () => { + it('closes the path (Z) and has four corners', () => { + const d = parallelogramPath(0, 0, 100, 50, 'left'); + expect(d.trim().endsWith('Z')).toBe(true); + // M + 3×L = 4 vertices + expect((d.match(/[ML]/g) ?? []).length).toBe(4); + }); + + it('left skews the top edge right by w*SKEW; bottom-left sits at x', () => { + const d = parallelogramPath(0, 0, 100, 50, 'left'); + // top-left vertex starts at x + skew + expect(d).toContain(`M ${100 * PARALLELOGRAM_SKEW} 0`); + // bottom-left vertex returns to x=0 + expect(d).toContain('L 0 50'); + }); + + it('right is the mirror of left (top-left at x, bottom-left at x+skew)', () => { + const d = parallelogramPath(0, 0, 100, 50, 'right'); + expect(d.startsWith('M 0 0')).toBe(true); + expect(d).toContain(`L ${100 * PARALLELOGRAM_SKEW} 50`); + }); + + it('respects the x/y origin offset', () => { + const d = parallelogramPath(10, 20, 100, 50, 'left'); + // top-left = (x + skew, y) = (30, 20) + expect(d).toContain(`M ${10 + 100 * PARALLELOGRAM_SKEW} 20`); + }); +}); + +describe('ovalPath', () => { + it('produces two elliptical arcs and closes', () => { + const d = ovalPath(0, 0, 100, 80); + expect((d.match(/A/g) ?? []).length).toBe(2); + expect(d.trim().endsWith('Z')).toBe(true); + }); + + it('uses half-width/half-height as the arc radii', () => { + const d = ovalPath(0, 0, 100, 80); + // rx=50 ry=40 appear in each arc command + expect(d).toContain('A 50 40'); + }); + + it('starts at the left-middle of the box, accounting for origin', () => { + const d = ovalPath(10, 20, 100, 80); + // start = (x, y + h/2) = (10, 60) + expect(d.startsWith('M 10 60')).toBe(true); + }); +}); + +describe('shapeClipPath', () => { + it('returns null for rectangular / unset / unknown shapes', () => { + expect(shapeClipPath(undefined, 0, 0, 10, 10)).toBeNull(); + expect(shapeClipPath('rectangle', 0, 0, 10, 10)).toBeNull(); + expect(shapeClipPath('pill', 0, 0, 10, 10)).toBeNull(); + }); + + it('maps circle to an oval path', () => { + expect(shapeClipPath('circle', 0, 0, 100, 80)).toBe(ovalPath(0, 0, 100, 80)); + }); + + it('maps parallelogram-left / -right to the matching parallelogram path', () => { + expect(shapeClipPath('parallelogram-left', 0, 0, 100, 50)) + .toBe(parallelogramPath(0, 0, 100, 50, 'left')); + expect(shapeClipPath('parallelogram-right', 0, 0, 100, 50)) + .toBe(parallelogramPath(0, 0, 100, 50, 'right')); + }); +}); diff --git a/src/renderer/nodes/shapes.ts b/src/renderer/nodes/shapes.ts new file mode 100644 index 0000000..ebd9105 --- /dev/null +++ b/src/renderer/nodes/shapes.ts @@ -0,0 +1,78 @@ +// Shared node-shape geometry, expressed as SVG path strings. +// +// Single source of truth for the parallelogram and circle/oval outlines used +// by `cc-shape-*` cards. Three call sites consume these: +// +// 1. SkiaCardRenderer — draws the shape fill (live tree). +// 2. useCanvasPicture — draws the same fill in the Picture overlay. +// 3. SkiaTextRenderer — clips body/label text to the shape outline so text +// doesn't bleed past a slanted parallelogram or curved circle edge (#53). +// +// Keeping the maths here means the clip outline can never silently drift from +// the fill outline. `` and the imperative `canvas.clipPath` both +// accept an SVG path string, so returning strings (rather than allocating an +// SkPath) keeps this dependency-free and works for every consumer. + +/** Horizontal skew of a parallelogram, as a fraction of its width. The top + * edge is shifted right by `w * SKEW`; the bottom edge left by the same. */ +export const PARALLELOGRAM_SKEW = 0.2; + +/** + * SVG path for a parallelogram filling the node's bounds. + * `left` leans the top edge right (╱-leaning); `right` mirrors it. + */ +export function parallelogramPath( + x: number, + y: number, + w: number, + h: number, + direction: 'left' | 'right', +): string { + const skew = w * PARALLELOGRAM_SKEW; + return direction === 'left' + ? `M ${x + skew} ${y} L ${x + w} ${y} L ${x + w - skew} ${y + h} L ${x} ${y + h} Z` + : `M ${x} ${y} L ${x + w - skew} ${y} L ${x + w} ${y + h} L ${x + skew} ${y + h} Z`; +} + +/** + * SVG path for an ellipse inscribed in the node's bounds — a true circle when + * the box is square, a stretched ellipse otherwise (matches the `` fill + * and Canvas Candy's `border-radius: 50%` convention). Two half-arcs sweep the + * full perimeter. + */ +export function ovalPath(x: number, y: number, w: number, h: number): string { + const rx = w / 2; + const ry = h / 2; + const cy = y + ry; + const left = x; + const right = x + w; + // Start at the left edge, arc over the top to the right edge, then back + // under the bottom. Sweep flag 1 = clockwise. + return `M ${left} ${cy} A ${rx} ${ry} 0 1 1 ${right} ${cy} A ${rx} ${ry} 0 1 1 ${left} ${cy} Z`; +} + +/** The `cc-shape-*` values that have a non-rectangular outline to clip to. */ +export type ClippableShape = 'circle' | 'parallelogram-left' | 'parallelogram-right'; + +/** + * SVG clip path for a node's shape, or `null` for rectangular / unset shapes + * (callers fall back to a plain bounding-rect clip in that case). + */ +export function shapeClipPath( + shape: string | undefined, + x: number, + y: number, + w: number, + h: number, +): string | null { + switch (shape) { + case 'circle': + return ovalPath(x, y, w, h); + case 'parallelogram-left': + return parallelogramPath(x, y, w, h, 'left'); + case 'parallelogram-right': + return parallelogramPath(x, y, w, h, 'right'); + default: + return null; + } +} diff --git a/src/renderer/useCanvasPicture.ts b/src/renderer/useCanvasPicture.ts index dd023d8..25e787a 100644 --- a/src/renderer/useCanvasPicture.ts +++ b/src/renderer/useCanvasPicture.ts @@ -8,6 +8,7 @@ import {getNodeColors, type ColorScheme} from './theme'; import {parseToSegments, toPlainText} from './markdown'; import {buildParagraph, getParagraphColours} from './paragraphBuilder'; import {resolveFileUri} from './utils/resolveFileUri'; +import {shapeClipPath, parallelogramPath} from './nodes/shapes'; // ---------- Fonts (duplicated from individual renderers — shared via Skia's internal cache) ---------- @@ -245,8 +246,6 @@ async function loadImageCache(uris: string[]): Promise { return cache; } -const PARALLELOGRAM_SKEW = 0.2; - // Mirrors `makeSideBorderPath` in SkiaCardRenderer.tsx — see that file for // the per-side geometry rationale. The two paths must stay in sync (Picture // recording vs live tree). @@ -278,13 +277,7 @@ function makeSideBorderPath(x: number, y: number, w: number, h: number, r: numbe } function makeParaPath(x: number, y: number, w: number, h: number, dir: 'left' | 'right') { - const skew = w * PARALLELOGRAM_SKEW; - const path = Skia.Path.MakeFromSVGString( - dir === 'left' - ? `M ${x + skew} ${y} L ${x + w} ${y} L ${x + w - skew} ${y + h} L ${x} ${y + h} Z` - : `M ${x} ${y} L ${x + w - skew} ${y} L ${x + w} ${y + h} L ${x + skew} ${y + h} Z` - ); - return path; + return Skia.Path.MakeFromSVGString(parallelogramPath(x, y, w, h, dir)); } function drawCard(canvas: SkCanvas, node: CanvasNode, colorScheme: ColorScheme) { @@ -497,17 +490,34 @@ function drawTextNode(canvas: SkCanvas, node: TextNode, colorScheme: ColorScheme const isDark = colorScheme === 'dark'; - // Clip the text-node output to its world bounds — mirrors the + // Clip the text-node output to the card outline — mirrors the // `` wrapper at the bottom of `SkiaTextRenderer` so the Picture - // overlay produces the same clipped result during pinch (#167). Every - // return path below MUST be preceded by `canvas.restore()` to keep the - // save stack balanced. + // overlay produces the same clipped result during pinch (#167). For + // circle / parallelogram cards, clip to the shape path so text respects the + // curved / slanted edge (#53); otherwise the bounding rect. Every return + // path below MUST be preceded by `canvas.restore()` to keep the save stack + // balanced. + const shape = enriched.renderProps?.shape; + const shapeClip = shapeClipPath(shape, node.x, node.y, node.width, node.height); canvas.save(); - canvas.clipRect( - {x: node.x, y: node.y, width: node.width, height: node.height}, - 1 /* ClipOp.Intersect */, - true, - ); + if (shapeClip) { + const clipPath = Skia.Path.MakeFromSVGString(shapeClip); + if (clipPath) { + canvas.clipPath(clipPath, 1 /* ClipOp.Intersect */, true); + } else { + canvas.clipRect( + {x: node.x, y: node.y, width: node.width, height: node.height}, + 1 /* ClipOp.Intersect */, + true, + ); + } + } else { + canvas.clipRect( + {x: node.x, y: node.y, width: node.width, height: node.height}, + 1 /* ClipOp.Intersect */, + true, + ); + } // Side labels — draw rotated text for label-only nodes if (labels.length > 0 && !bodyText.trim() && !header && !footer) { @@ -587,7 +597,6 @@ function drawTextNode(canvas: SkCanvas, node: TextNode, colorScheme: ColorScheme return; } - const shape = enriched.renderProps?.shape; const centerText = enriched.renderProps?.textAlign === 'center' || centered != null || shape === 'circle' || shape === 'parallelogram-left' || shape === 'parallelogram-right';