openpencil/packages/pen-renderer/src/node-renderer.ts
Fini b49060d59f fix(renderer): drop-shadow follows cornerRadius / ellipse outline
User-reported 2026-05-10 "圆角元素的尖角阴影" — rounded cards / hero
images had visibly square-cornered drop shadows poking out from
under the rounded shape. Forensic root cause: applyShadowDirect at
node-renderer.ts:447 was always drawing the shadow as a plain
`canvas.drawRect(...)`, completely ignoring the node's cornerRadius.
A frame with cornerRadius=24 + a subtle drop shadow would render the
rounded body cleanly but stamp a sharp-cornered shadow rectangle
just behind it, with the rectangle corners visible past the rounded
outline.

Fix: pass the node's cornerRadius into applyShadowDirect; when > 0
use `drawRRect` with `RRectXY(rect, cornerRadius+spread, ...)` so the
shadow's rounding stays parallel to the node's rounding (the +spread
correction keeps the visible curve aligned when spread expands /
contracts the bounds).

Ellipse / circle nodes (avatars, status dots) get cornerRadius =
min(w,h)/2 from the call site so their shadows render as stadium /
circle. Asymmetric-aspect ellipses get a stadium approximation
rather than a true ellipse — accepted simplification, the common
case is symmetric (avatar / dot).

Path / line / polygon nodes have no cornerRadius and fall through
with cr=0 — rectangular shadow stays correct for them.

This is a renderer-layer fix that detector-only paths can't reach;
ships in the same session as the typography / spacing detectors so
the user sees end-to-end aesthetic improvement on the next rebuild.
2026-05-10 22:53:58 +08:00

1206 lines
36 KiB
TypeScript

import type { CanvasKit, Canvas, Paint, Font, Typeface } from 'canvaskit-wasm';
import type {
PenNode,
ContainerProps,
EllipseNode,
LineNode,
PolygonNode,
PathNode,
ImageNode,
IconFontNode,
} from '@zseven-w/pen-types';
import type { PenFill, PenStroke, PenEffect, ShadowEffect, ImageFill } from '@zseven-w/pen-types';
import {
DEFAULT_FILL,
DEFAULT_STROKE,
DEFAULT_STROKE_WIDTH,
buildEllipseArcPath,
getPathBoundsFromAnchors,
isArcEllipse,
pathDataToAnchors,
} from '@zseven-w/pen-core';
import {
parseColor,
cornerRadiusValue,
cornerRadii,
resolveFillColor,
shouldUseTransparentFallbackFill,
resolveStrokeColor,
resolveStrokeWidth,
} from './paint-utils.js';
import { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './path-utils.js';
import { SkiaImageLoader } from './image-loader.js';
import { SkiaTextRenderer } from './text-renderer.js';
import type { SkiaFontManager, FontManagerOptions } from './font-manager.js';
import type { RenderNode, IconLookupFn } from './types.js';
const FALLBACK_ICON_D = 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0';
/**
* Core node renderer for CanvasKit/Skia. Draws PenNode shapes, fills,
* strokes, effects, text, and images. No editor overlays or store dependencies.
*/
export class SkiaNodeRenderer {
protected ck: CanvasKit;
private defaultTypeface: Typeface | null = null;
private defaultFont: Font | null = null;
// Current viewport zoom (set by engine before each render frame)
zoom = 1;
// Device pixel ratio
devicePixelRatio: number | undefined;
// Sub-renderers
private textRenderer: SkiaTextRenderer;
imageLoader: SkiaImageLoader;
// Injectable icon lookup
private iconLookup: IconLookupFn | null = null;
/** Font manager — delegates to text renderer */
get fontManager(): SkiaFontManager {
return this.textRenderer.fontManager;
}
constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
this.ck = ck;
this.imageLoader = new SkiaImageLoader(ck);
this.textRenderer = new SkiaTextRenderer(ck, fontOptions);
}
init() {
this.defaultFont = new this.ck.Font(null, 16);
}
/** Set callback to trigger re-render when async images finish loading. */
setRedrawCallback(cb: () => void) {
this.imageLoader.setOnLoaded(cb);
}
/** Set injectable icon lookup function. */
setIconLookup(fn: IconLookupFn) {
this.iconLookup = fn;
}
setImageSourceResolver(resolver: (src: string) => { cacheKey: string; loadUrl: string | null }) {
this.imageLoader.setSourceResolver(resolver);
}
dispose() {
this.defaultFont?.delete();
this.defaultFont = null;
this.defaultTypeface?.delete();
this.defaultTypeface = null;
this.textRenderer.dispose();
this.imageLoader.dispose();
}
clearTextCache() {
this.textRenderer.clearTextCache();
}
clearParaCache() {
this.textRenderer.clearParaCache();
}
// ---------------------------------------------------------------------------
// Fill paint
// ---------------------------------------------------------------------------
private makeFillPaint(
fills: PenFill[] | string | undefined,
w: number,
h: number,
opacity: number,
absX: number,
absY: number,
): {
paint: Paint;
imageFillDraw?: {
fill: ImageFill;
w: number;
h: number;
absX: number;
absY: number;
opacity: number;
};
} {
const ck = this.ck;
const paint = new ck.Paint();
paint.setStyle(ck.PaintStyle.Fill);
paint.setAntiAlias(true);
if (typeof fills === 'string') {
const c = parseColor(ck, fills);
c[3] *= opacity;
paint.setColor(c);
return { paint };
}
if (!fills || fills.length === 0) {
const c = parseColor(ck, DEFAULT_FILL);
c[3] *= opacity;
paint.setColor(c);
return { paint };
}
const first = fills[0];
if (first.type === 'solid') {
const c = parseColor(ck, first.color);
c[3] *= (first.opacity ?? 1) * opacity;
paint.setColor(c);
} else if (first.type === 'linear_gradient') {
const stops = first.stops ?? [];
const fillOpacity = (first.opacity ?? 1) * opacity;
if (stops.length >= 2) {
const rad = (((first.angle ?? 0) - 90) * Math.PI) / 180;
const cos = Math.cos(rad),
sin = Math.sin(rad);
const x1 = absX + w / 2 - (cos * w) / 2,
y1 = absY + h / 2 - (sin * h) / 2;
const x2 = absX + w / 2 + (cos * w) / 2,
y2 = absY + h / 2 + (sin * h) / 2;
const colors = stops.map((s) => {
const c = parseColor(ck, s.color);
c[3] *= fillOpacity;
return c;
});
const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)));
const shader = ck.Shader.MakeLinearGradient(
[x1, y1],
[x2, y2],
colors,
positions,
ck.TileMode.Clamp,
);
if (shader) paint.setShader(shader);
} else {
const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL);
c[3] *= fillOpacity;
paint.setColor(c);
}
} else if (first.type === 'radial_gradient') {
const stops = first.stops ?? [];
const fillOpacity = (first.opacity ?? 1) * opacity;
if (stops.length >= 2) {
const cx = absX + (first.cx ?? 0.5) * w,
cy = absY + (first.cy ?? 0.5) * h;
const r = (first.radius ?? 0.5) * Math.max(w, h);
const colors = stops.map((s) => {
const c = parseColor(ck, s.color);
c[3] *= fillOpacity;
return c;
});
const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)));
const shader = ck.Shader.MakeRadialGradient(
[cx, cy],
r,
colors,
positions,
ck.TileMode.Clamp,
);
if (shader) paint.setShader(shader);
} else {
const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL);
c[3] *= fillOpacity;
paint.setColor(c);
}
} else if (first.type === 'image') {
const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY);
if (result.needsDrawImageRect && result.fill) {
return {
paint,
imageFillDraw: {
fill: result.fill,
w: result.w!,
h: result.h!,
absX: result.absX!,
absY: result.absY!,
opacity: result.opacity!,
},
};
}
}
return { paint };
}
private applyImageFillToPaint(
paint: Paint,
fill: ImageFill,
w: number,
h: number,
opacity: number,
absX: number,
absY: number,
): {
needsDrawImageRect: boolean;
fill?: ImageFill;
w?: number;
h?: number;
absX?: number;
absY?: number;
opacity?: number;
} {
const ck = this.ck;
const fillOpacity = (fill.opacity ?? 1) * opacity;
const url = fill.url;
if (!url) {
const c = parseColor(ck, '#e5e7eb');
c[3] *= fillOpacity;
paint.setColor(c);
return { needsDrawImageRect: false };
}
const cached = this.imageLoader.get(url);
if (cached === undefined) this.imageLoader.request(url);
if (!cached) {
const isMissing = this.imageLoader.getStatus(url)?.state === 'missing';
const c = parseColor(ck, isMissing ? '#f1d7d7' : '#e5e7eb');
c[3] *= fillOpacity;
paint.setColor(c);
return { needsDrawImageRect: false };
}
const imgW = cached.width(),
imgH = cached.height();
if (imgW <= 0 || imgH <= 0) return { needsDrawImageRect: false };
const mode = fill.mode ?? 'fill';
if (mode === 'tile') {
const dispX = absX + (w - imgW) / 2,
dispY = absY + (h - imgH) / 2;
const localMatrix = Float32Array.of(1, 0, -dispX, 0, 1, -dispY, 0, 0, 1);
const shader = cached.makeShaderOptions(
ck.TileMode.Repeat,
ck.TileMode.Repeat,
ck.FilterMode.Linear,
ck.MipmapMode.None,
localMatrix,
);
if (shader) {
paint.setShader(shader);
if (fillOpacity < 1) paint.setAlphaf(fillOpacity);
const cf = this.buildImageAdjustmentFilter(fill);
if (cf) paint.setColorFilter(cf);
}
return { needsDrawImageRect: false };
}
paint.setColor(Float32Array.of(0, 0, 0, 0));
return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity };
}
private drawImageFillRect(
canvas: Canvas,
fill: ImageFill,
w: number,
h: number,
absX: number,
absY: number,
fillOpacity: number,
) {
const ck = this.ck;
const url = fill.url;
if (!url) return;
const cached = this.imageLoader.get(url);
if (!cached) return;
const imgW = cached.width(),
imgH = cached.height();
if (imgW <= 0 || imgH <= 0) return;
const mode = fill.mode ?? 'fill';
const paint = new ck.Paint();
paint.setAntiAlias(true);
if (fillOpacity < 1) paint.setAlphaf(fillOpacity);
const adjFilter = this.buildImageAdjustmentFilter(fill);
if (adjFilter) paint.setColorFilter(adjFilter);
if (mode === 'fit') {
const scale = Math.min(w / imgW, h / imgH);
const dw = imgW * scale,
dh = imgH * scale;
const dx = absX + (w - dw) / 2,
dy = absY + (h - dh) / 2;
canvas.drawImageRect(
cached,
ck.LTRBRect(0, 0, imgW, imgH),
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
paint,
);
} else if (mode === 'stretch') {
canvas.drawImageRect(
cached,
ck.LTRBRect(0, 0, imgW, imgH),
ck.LTRBRect(absX, absY, absX + w, absY + h),
paint,
);
} else {
const scale = Math.max(w / imgW, h / imgH);
const dw = imgW * scale,
dh = imgH * scale;
const dx = absX + (w - dw) / 2,
dy = absY + (h - dh) / 2;
canvas.drawImageRect(
cached,
ck.LTRBRect(0, 0, imgW, imgH),
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
paint,
);
}
paint.delete();
}
private buildImageAdjustmentFilter(adj: {
exposure?: number;
contrast?: number;
saturation?: number;
temperature?: number;
tint?: number;
highlights?: number;
shadows?: number;
}) {
const ck = this.ck;
const exp = (adj.exposure ?? 0) / 100,
con = (adj.contrast ?? 0) / 100,
sat = (adj.saturation ?? 0) / 100;
const temp = (adj.temperature ?? 0) / 100,
tintVal = (adj.tint ?? 0) / 100;
const hi = (adj.highlights ?? 0) / 100,
sh = (adj.shadows ?? 0) / 100;
if (exp === 0 && con === 0 && sat === 0 && temp === 0 && tintVal === 0 && hi === 0 && sh === 0)
return null;
const e = 1 + exp * 1.5,
c = 1 + con,
cOff = 0.5 * (1 - c);
const s = 1 + sat;
const lr = 0.2126,
lg = 0.7152,
lb = 0.0722;
const sr = (1 - s) * lr,
sg = (1 - s) * lg,
sb = (1 - s) * lb;
const f = c * e;
const offR = cOff + temp * 0.15 + (hi + sh * 0.5) * 0.1;
const offG = cOff + tintVal * 0.15 + (hi + sh * 0.5) * 0.1;
const offB = cOff - temp * 0.15 + (hi + sh * 0.5) * 0.1;
return ck.ColorFilter.MakeMatrix([
f * (sr + s),
f * sg,
f * sb,
0,
offR,
f * sr,
f * (sg + s),
f * sb,
0,
offG,
f * sr,
f * sg,
f * (sb + s),
0,
offB,
0,
0,
0,
1,
0,
]);
}
// ---------------------------------------------------------------------------
// Stroke paint
// ---------------------------------------------------------------------------
private makeStrokePaint(stroke: PenStroke | undefined, opacity: number): Paint | null {
if (!stroke) return null;
const strokeColor = resolveStrokeColor(stroke);
const strokeWidth = resolveStrokeWidth(stroke);
if (!strokeColor || strokeWidth <= 0) return null;
const ck = this.ck;
const paint = new ck.Paint();
paint.setStyle(ck.PaintStyle.Stroke);
paint.setAntiAlias(true);
paint.setStrokeWidth(strokeWidth);
const c = parseColor(ck, strokeColor);
c[3] *= opacity;
paint.setColor(c);
if (stroke.join === 'round') paint.setStrokeJoin(ck.StrokeJoin.Round);
else if (stroke.join === 'bevel') paint.setStrokeJoin(ck.StrokeJoin.Bevel);
if (stroke.cap === 'round') paint.setStrokeCap(ck.StrokeCap.Round);
else if (stroke.cap === 'square') paint.setStrokeCap(ck.StrokeCap.Square);
if (stroke.dashPattern && stroke.dashPattern.length >= 2) {
const effect = ck.PathEffect.MakeDash(stroke.dashPattern, stroke.dashOffset ?? 0);
if (effect) paint.setPathEffect(effect);
}
return paint;
}
// ---------------------------------------------------------------------------
// Shadow
// ---------------------------------------------------------------------------
private applyShadowDirect(
canvas: Canvas,
effects: PenEffect[] | undefined,
x: number,
y: number,
w: number,
h: number,
cornerRadius: number = 0,
): boolean {
if (!effects) return false;
const shadow = effects.find((e): e is ShadowEffect => e.type === 'shadow');
if (!shadow) return false;
const ck = this.ck;
const paint = new ck.Paint();
paint.setStyle(ck.PaintStyle.Fill);
paint.setAntiAlias(true);
paint.setColor(parseColor(ck, shadow.color));
paint.setMaskFilter(ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, shadow.blur / 2, true));
const rect = ck.LTRBRect(
x + shadow.offsetX - shadow.spread,
y + shadow.offsetY - shadow.spread,
x + w + shadow.offsetX + shadow.spread,
y + h + shadow.offsetY + shadow.spread,
);
if (cornerRadius > 0) {
// 2026-05-10 user-reported "圆角元素的尖角阴影" — drawRect was producing
// a sharp-cornered shadow under rounded frames / cards because the
// shadow geometry never inherited the node's cornerRadius. Drop
// shadows MUST follow the node's outline, otherwise the corners poke
// out from under the rounded shape.
//
// Spread expands (or contracts) the visible shadow edge by `spread`
// px on each side, so the visible corner radius needs the same
// offset to stay parallel to the node — clamp to ≥ 0 so a negative
// spread doesn't invert the curve.
const shadowR = Math.max(0, cornerRadius + shadow.spread);
canvas.drawRRect(ck.RRectXY(rect, shadowR, shadowR), paint);
} else {
canvas.drawRect(rect, paint);
}
paint.delete();
return true;
}
// ---------------------------------------------------------------------------
// Draw a single render node (no selection/overlay logic)
// ---------------------------------------------------------------------------
drawNode(canvas: Canvas, rn: RenderNode) {
const { node, absX, absY, absW, absH, clipStack } = rn;
const ck = this.ck;
const opacity = typeof node.opacity === 'number' ? node.opacity : 1;
if (('visible' in node ? node.visible : undefined) === false) return;
// Pass zoom to text renderer
this.textRenderer.zoom = this.zoom;
this.textRenderer.devicePixelRatio = this.devicePixelRatio;
// Apply ancestor-to-self clip stack. Each ancestor with `clipContent: true`
// (and the root frame artboard) pushed a ClipInfo onto this stack at
// flatten time. Pushing each as a separate canvas.save+clip preserves
// every level's rounded corner — a single ClipInfo can't encode the
// intersection of two rrects when one cuts inside the other's corner.
let clipsPushed = 0;
if (clipStack && clipStack.length > 0) {
for (const clip of clipStack) {
canvas.save();
clipsPushed++;
if (clip.rx > 0) {
canvas.clipRRect(
ck.RRectXY(
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
clip.rx,
clip.rx,
),
ck.ClipOp.Intersect,
true,
);
} else {
canvas.clipRect(
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
ck.ClipOp.Intersect,
true,
);
}
}
}
// Apply flip
const flipX = node.flipX === true,
flipY = node.flipY === true;
if (flipX || flipY) {
canvas.save();
canvas.translate(absX + absW / 2, absY + absH / 2);
canvas.scale(flipX ? -1 : 1, flipY ? -1 : 1);
canvas.translate(-(absX + absW / 2), -(absY + absH / 2));
}
// Apply rotation
const rotation = node.rotation ?? 0;
if (rotation !== 0) {
canvas.save();
canvas.rotate(rotation, absX + absW / 2, absY + absH / 2);
}
// Apply shadow (text uses glyph-shaped shadow, not rectangle)
const effects =
'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined;
if (node.type !== 'text') {
// Pass the node's cornerRadius so the drop-shadow follows the rounded
// outline. Path / line / polygon don't carry cornerRadius and fall
// through with cr=0 (rectangular shadow, which is fine for them).
// Ellipse maps to cornerRadius = min(w,h)/2 — RRect with rx=ry=
// half-min produces a stadium / circle shadow that matches a
// symmetric ellipse outline; asymmetric ellipses get a stadium
// approximation rather than a true ellipse, accepted as known
// simplification (avatar / status-dot use circles).
const cr =
node.type === 'ellipse'
? Math.min(absW, absH) / 2
: cornerRadiusValue(
(node as PenNode & { cornerRadius?: number | [number, number, number, number] })
.cornerRadius,
);
this.applyShadowDirect(canvas, effects, absX, absY, absW, absH, cr);
}
switch (node.type) {
case 'frame':
case 'rectangle':
case 'group':
this.drawRect(canvas, node, absX, absY, absW, absH, opacity);
break;
case 'ellipse':
this.drawEllipse(canvas, node, absX, absY, absW, absH, opacity);
break;
case 'line':
this.drawLine(canvas, node, absX, absY, opacity);
break;
case 'polygon':
this.drawPolygon(canvas, node, absX, absY, absW, absH, opacity);
break;
case 'path':
this.drawPath(canvas, node, absX, absY, absW, absH, opacity);
break;
case 'icon_font':
this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity);
break;
case 'text':
this.textRenderer.drawText(canvas, node, absX, absY, absW, absH, opacity, effects);
break;
case 'image':
this.drawImage(canvas, node, absX, absY, absW, absH, opacity);
break;
}
if (rotation !== 0) canvas.restore();
if (flipX || flipY) canvas.restore();
for (let i = 0; i < clipsPushed; i++) canvas.restore();
}
// ---------------------------------------------------------------------------
// Shape drawing
// ---------------------------------------------------------------------------
private drawRect(
canvas: Canvas,
node: PenNode,
x: number,
y: number,
w: number,
h: number,
opacity: number,
) {
const ck = this.ck;
const container = node as PenNode & ContainerProps;
const cr = cornerRadii(container.cornerRadius);
const fills = container.fill;
const stroke = container.stroke;
const hasFill = fills && fills.length > 0;
const isContainer = node.type === 'frame' || node.type === 'group';
const { paint: fillPaint, imageFillDraw } = this.makeFillPaint(
hasFill || !shouldUseTransparentFallbackFill(fills, stroke, isContainer)
? fills
: 'transparent',
w,
h,
opacity,
x,
y,
);
const hasRoundedCorners = cr.some((r) => r > 0);
if (hasRoundedCorners) {
const maxR = Math.min(w / 2, h / 2);
canvas.drawRRect(
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
fillPaint,
);
} else {
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint);
}
fillPaint.delete();
if (imageFillDraw) {
canvas.save();
if (hasRoundedCorners) {
const maxR = Math.min(w / 2, h / 2);
canvas.clipRRect(
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
ck.ClipOp.Intersect,
true,
);
} else {
canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true);
}
this.drawImageFillRect(
canvas,
imageFillDraw.fill,
imageFillDraw.w,
imageFillDraw.h,
imageFillDraw.absX,
imageFillDraw.absY,
imageFillDraw.opacity,
);
canvas.restore();
}
const strokePaint = this.makeStrokePaint(stroke, opacity);
if (strokePaint) {
if (hasRoundedCorners) {
const maxR = Math.min(w / 2, h / 2);
canvas.drawRRect(
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
strokePaint,
);
} else {
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint);
}
strokePaint.delete();
}
}
private drawEllipse(
canvas: Canvas,
node: PenNode,
x: number,
y: number,
w: number,
h: number,
opacity: number,
) {
const ck = this.ck;
const eNode = node as EllipseNode;
const fills = eNode.fill,
stroke = eNode.stroke;
const cr = cornerRadiusValue(eNode.cornerRadius);
const fillSource = shouldUseTransparentFallbackFill(fills, stroke) ? 'transparent' : fills;
if (isArcEllipse(eNode.startAngle, eNode.sweepAngle, eNode.innerRadius)) {
const arcD = buildEllipseArcPath(
w,
h,
eNode.startAngle ?? 0,
eNode.sweepAngle ?? 360,
eNode.innerRadius ?? 0,
);
const path = ck.Path.MakeFromSVGString(arcD);
if (path) {
path.offset(x, y);
const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
if (cr > 0) {
const effect = ck.PathEffect.MakeCorner(cr);
if (effect) fillPaint.setPathEffect(effect);
}
canvas.drawPath(path, fillPaint);
fillPaint.delete();
const strokePaint = this.makeStrokePaint(stroke, opacity);
if (strokePaint) {
if (cr > 0) {
const effect = ck.PathEffect.MakeCorner(cr);
if (effect) strokePaint.setPathEffect(effect);
}
canvas.drawPath(path, strokePaint);
strokePaint.delete();
}
path.delete();
}
return;
}
const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint);
fillPaint.delete();
const strokePaint = this.makeStrokePaint(stroke, opacity);
if (strokePaint) {
canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint);
strokePaint.delete();
}
}
private drawLine(canvas: Canvas, node: PenNode, x: number, y: number, opacity: number) {
const ck = this.ck;
const lNode = node as LineNode;
const x2 = lNode.x2 ?? x + 100,
y2 = lNode.y2 ?? y;
const strokeColor = resolveStrokeColor(lNode.stroke) ?? DEFAULT_STROKE;
const strokeWidth = resolveStrokeWidth(lNode.stroke) || DEFAULT_STROKE_WIDTH;
const paint = new ck.Paint();
paint.setStyle(ck.PaintStyle.Stroke);
paint.setAntiAlias(true);
paint.setStrokeWidth(strokeWidth);
const c = parseColor(ck, strokeColor);
c[3] *= opacity;
paint.setColor(c);
canvas.drawLine(x, y, x2, y2, paint);
paint.delete();
}
private drawPolygon(
canvas: Canvas,
node: PenNode,
x: number,
y: number,
w: number,
h: number,
opacity: number,
) {
const ck = this.ck;
const pNode = node as PolygonNode;
const count = pNode.polygonCount || 6;
const fills = pNode.fill,
stroke = pNode.stroke;
const cr = cornerRadiusValue(pNode.cornerRadius);
const fillSource = shouldUseTransparentFallbackFill(fills, stroke) ? 'transparent' : fills;
const raw: [number, number][] = [];
for (let i = 0; i < count; i++) {
const angle = (i * 2 * Math.PI) / count - Math.PI / 2;
raw.push([Math.cos(angle), Math.sin(angle)]);
}
let minX = Infinity,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity;
for (const [rx, ry] of raw) {
if (rx < minX) minX = rx;
if (rx > maxX) maxX = rx;
if (ry < minY) minY = ry;
if (ry > maxY) maxY = ry;
}
const rawW = maxX - minX,
rawH = maxY - minY;
const path = new ck.Path();
for (let i = 0; i < count; i++) {
const px = x + ((raw[i][0] - minX) / rawW) * w,
py = y + ((raw[i][1] - minY) / rawH) * h;
if (i === 0) path.moveTo(px, py);
else path.lineTo(px, py);
}
path.close();
const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
if (cr > 0) {
const effect = ck.PathEffect.MakeCorner(cr);
if (effect) fillPaint.setPathEffect(effect);
}
canvas.drawPath(path, fillPaint);
fillPaint.delete();
const strokePaint = this.makeStrokePaint(stroke, opacity);
if (strokePaint) {
if (cr > 0) {
const effect = ck.PathEffect.MakeCorner(cr);
if (effect) strokePaint.setPathEffect(effect);
}
canvas.drawPath(path, strokePaint);
strokePaint.delete();
}
path.delete();
}
private drawPath(
canvas: Canvas,
node: PenNode,
x: number,
y: number,
w: number,
h: number,
opacity: number,
) {
const ck = this.ck;
const pNode = node as PathNode;
const rawD = typeof pNode.d === 'string' && pNode.d.trim().length > 0 ? pNode.d : 'M0 0 L0 0';
const fills = pNode.fill,
stroke = pNode.stroke;
let path: ReturnType<typeof ck.Path.MakeFromSVGString> = null;
if (hasInvalidNumbers(rawD)) {
path = tryManualPathParse(ck, rawD);
} else {
const d = sanitizeSvgPath(rawD);
path = ck.Path.MakeFromSVGString(d);
if (!path && d !== rawD) path = ck.Path.MakeFromSVGString(rawD);
if (!path) path = tryManualPathParse(ck, rawD);
}
if (!path) {
if (w > 0 && h > 0) {
const { paint: fp } = this.makeFillPaint(fills, w, h, opacity, x, y);
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fp);
fp.delete();
}
return;
}
const parsedAnchors = pNode.anchors
? {
anchors: pNode.anchors,
closed: pNode.closed ?? /[Zz]\s*$/.test(rawD),
}
: pathDataToAnchors(rawD);
const geometryBounds = parsedAnchors
? getPathBoundsFromAnchors(parsedAnchors.anchors, parsedAnchors.closed)
: null;
const bounds = geometryBounds
? Float32Array.of(
geometryBounds.x,
geometryBounds.y,
geometryBounds.x + geometryBounds.width,
geometryBounds.y + geometryBounds.height,
)
: path.getBounds();
const nativeW = bounds[2] - bounds[0],
nativeH = bounds[3] - bounds[1];
if (w > 0 && h > 0 && nativeW > 0.01 && nativeH > 0.01) {
const isIcon = !!pNode.iconId;
const sx = isIcon ? Math.min(w / nativeW, h / nativeH) : w / nativeW;
const sy = isIcon ? sx : h / nativeH;
path.transform(
ck.Matrix.multiply(
ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
ck.Matrix.scaled(sx, sy),
),
);
} else if (nativeW > 0.01 || nativeH > 0.01) {
const sx = nativeW > 0.01 && w > 0 ? w / nativeW : 1,
sy = nativeH > 0.01 && h > 0 ? h / nativeH : 1;
path.transform(
ck.Matrix.multiply(
ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
ck.Matrix.scaled(sx, sy),
),
);
} else {
path.offset(x, y);
}
const hasExplicitFill = fills && fills.length > 0;
const strokeColor = resolveStrokeColor(stroke),
strokeWidth = resolveStrokeWidth(stroke);
const hasVisibleStroke = strokeWidth > 0 && !!strokeColor;
if (hasExplicitFill || !hasVisibleStroke) {
const { paint: fillPaint } = this.makeFillPaint(
hasExplicitFill ? fills : undefined,
w,
h,
opacity,
x,
y,
);
const closeCount = (rawD.match(/Z/gi) || []).length;
path.setFillType(closeCount > 1 ? ck.FillType.EvenOdd : ck.FillType.Winding);
canvas.drawPath(path, fillPaint);
fillPaint.delete();
}
if (hasVisibleStroke) {
const sp = this.makeStrokePaint(stroke, opacity);
if (sp) {
canvas.drawPath(path, sp);
sp.delete();
}
}
path.delete();
}
private drawIconFont(
canvas: Canvas,
node: PenNode,
x: number,
y: number,
w: number,
h: number,
opacity: number,
) {
const ck = this.ck;
const iNode = node as IconFontNode;
const iconName = iNode.iconFontName ?? iNode.name ?? '';
const iconMatch = this.iconLookup?.(iconName) ?? null;
const iconD = iconMatch?.d ?? FALLBACK_ICON_D;
const iconStyle = iconMatch?.style ?? 'stroke';
const rawFill = iNode.fill;
const iconFillColor =
typeof rawFill === 'string'
? rawFill
: Array.isArray(iNode.fill) && iNode.fill.length > 0
? resolveFillColor(iNode.fill)
: '#64748B';
const sanitizedIconD = sanitizeSvgPath(iconD);
let path = ck.Path.MakeFromSVGString(sanitizedIconD);
if (!path && sanitizedIconD !== iconD) path = ck.Path.MakeFromSVGString(iconD);
if (!path) path = tryManualPathParse(ck, iconD);
if (!path) return;
const bounds = path.getBounds();
const nativeW = bounds[2] - bounds[0],
nativeH = bounds[3] - bounds[1];
if (w > 0 && h > 0 && nativeW > 0 && nativeH > 0) {
const s = Math.min(w / nativeW, h / nativeH);
path.transform(
ck.Matrix.multiply(
ck.Matrix.translated(x - bounds[0] * s, y - bounds[1] * s),
ck.Matrix.scaled(s, s),
),
);
} else {
path.offset(x, y);
}
const paint = new ck.Paint();
paint.setAntiAlias(true);
const c = parseColor(ck, iconFillColor);
c[3] *= opacity;
paint.setColor(c);
if (iconStyle === 'stroke') {
paint.setStyle(ck.PaintStyle.Stroke);
paint.setStrokeWidth(2);
paint.setStrokeCap(ck.StrokeCap.Round);
paint.setStrokeJoin(ck.StrokeJoin.Round);
} else {
paint.setStyle(ck.PaintStyle.Fill);
path.setFillType(ck.FillType.EvenOdd);
}
canvas.drawPath(path, paint);
paint.delete();
path.delete();
}
// ---------------------------------------------------------------------------
// Image drawing
// ---------------------------------------------------------------------------
private drawImage(
canvas: Canvas,
node: PenNode,
x: number,
y: number,
w: number,
h: number,
opacity: number,
) {
const ck = this.ck;
const iNode = node as ImageNode;
const src: string | undefined = iNode.src;
const cr = cornerRadiusValue(iNode.cornerRadius);
if (!src) {
this.drawImageFallback(canvas, x, y, w, h, cr, opacity, false);
return;
}
const cached = this.imageLoader.get(src);
if (cached === undefined) {
this.imageLoader.request(src);
this.drawImageFallback(canvas, x, y, w, h, cr, opacity, false);
return;
}
if (!cached) {
const status = this.imageLoader.getStatus(src);
this.drawImageFallback(
canvas,
x,
y,
w,
h,
cr,
opacity,
status?.state === 'missing' || status?.state === 'error',
);
return;
}
const imgW = cached.width(),
imgH = cached.height();
if (cr > 0) {
canvas.save();
const maxR = Math.min(cr, w / 2, h / 2);
canvas.clipRRect(
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR),
ck.ClipOp.Intersect,
true,
);
} else {
canvas.save();
canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true);
}
const paint = new ck.Paint();
paint.setAntiAlias(true);
if (opacity < 1) paint.setAlphaf(opacity);
const adjFilter = this.buildImageAdjustmentFilter(iNode);
if (adjFilter) paint.setColorFilter(adjFilter);
const fit = iNode.objectFit ?? 'fill';
if (fit === 'tile') {
const tileMatrix = Float32Array.of(1, 0, -x, 0, 1, -y, 0, 0, 1);
const shader = cached.makeShaderOptions(
ck.TileMode.Repeat,
ck.TileMode.Repeat,
ck.FilterMode.Linear,
ck.MipmapMode.None,
tileMatrix,
);
if (shader) {
paint.setShader(shader);
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint);
}
} else if (fit === 'fit') {
const bgPaint = new ck.Paint();
bgPaint.setStyle(ck.PaintStyle.Fill);
bgPaint.setColor(parseColor(ck, '#f3f4f6'));
if (opacity < 1) bgPaint.setAlphaf(opacity * 0.3);
else bgPaint.setAlphaf(0.3);
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), bgPaint);
bgPaint.delete();
const scale = Math.min(w / imgW, h / imgH),
dw = imgW * scale,
dh = imgH * scale;
const dx = x + (w - dw) / 2,
dy = y + (h - dh) / 2;
canvas.drawImageRect(
cached,
ck.LTRBRect(0, 0, imgW, imgH),
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
paint,
);
} else {
const scale = Math.max(w / imgW, h / imgH),
dw = imgW * scale,
dh = imgH * scale;
const dx = x + (w - dw) / 2,
dy = y + (h - dh) / 2;
canvas.drawImageRect(
cached,
ck.LTRBRect(0, 0, imgW, imgH),
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
paint,
);
}
paint.delete();
canvas.restore();
}
private drawImageFallback(
canvas: Canvas,
x: number,
y: number,
w: number,
h: number,
cr: number,
opacity: number,
emphasizeMissing: boolean,
) {
const ck = this.ck;
const rect = ck.LTRBRect(x, y, x + w, y + h);
const maxR = Math.min(cr, w / 2, h / 2);
const fillPaint = new ck.Paint();
fillPaint.setStyle(ck.PaintStyle.Fill);
fillPaint.setAntiAlias(true);
const fillColor = parseColor(ck, emphasizeMissing ? '#f8d7da' : '#e5e7eb');
fillColor[3] *= opacity;
fillPaint.setColor(fillColor);
if (cr > 0) {
canvas.drawRRect(ck.RRectXY(rect, maxR, maxR), fillPaint);
} else {
canvas.drawRect(rect, fillPaint);
}
fillPaint.delete();
const strokePaint = new ck.Paint();
strokePaint.setStyle(ck.PaintStyle.Stroke);
strokePaint.setAntiAlias(true);
strokePaint.setStrokeWidth(Math.max(1, Math.min(w, h) * 0.02));
const strokeColor = parseColor(ck, emphasizeMissing ? '#c2410c' : '#94a3b8');
strokeColor[3] *= opacity;
strokePaint.setColor(strokeColor);
const dash = ck.PathEffect.MakeDash([8, 6], 0);
if (dash) strokePaint.setPathEffect(dash);
if (cr > 0) {
canvas.drawRRect(ck.RRectXY(rect, maxR, maxR), strokePaint);
} else {
canvas.drawRect(rect, strokePaint);
}
const iconPaint = new ck.Paint();
iconPaint.setStyle(ck.PaintStyle.Stroke);
iconPaint.setAntiAlias(true);
iconPaint.setStrokeWidth(Math.max(1.25, Math.min(w, h) * 0.03));
iconPaint.setStrokeCap(ck.StrokeCap.Round);
iconPaint.setStrokeJoin(ck.StrokeJoin.Round);
iconPaint.setColor(strokeColor);
const inset = Math.max(10, Math.min(w, h) * 0.18);
const iconLeft = x + inset;
const iconTop = y + inset;
const iconRight = x + w - inset;
const iconBottom = y + h - inset;
const iconPath = new ck.Path();
iconPath.moveTo(iconLeft, iconBottom);
iconPath.lineTo(x + w * 0.42, y + h * 0.58);
iconPath.lineTo(x + w * 0.58, y + h * 0.72);
iconPath.lineTo(iconRight, y + h * 0.42);
iconPath.moveTo(iconLeft, iconTop);
iconPath.lineTo(iconRight, iconTop);
iconPath.lineTo(iconRight, iconBottom);
iconPath.lineTo(iconLeft, iconBottom);
iconPath.close();
canvas.drawPath(iconPath, iconPaint);
iconPath.delete();
const dotPaint = new ck.Paint();
dotPaint.setStyle(ck.PaintStyle.Fill);
dotPaint.setAntiAlias(true);
dotPaint.setColor(strokeColor);
canvas.drawCircle(x + w * 0.35, y + h * 0.38, Math.max(3, Math.min(w, h) * 0.05), dotPaint);
if (emphasizeMissing) {
canvas.drawLine(
x + inset * 0.9,
y + h - inset * 0.9,
x + w - inset * 0.9,
y + inset * 0.9,
iconPaint,
);
}
strokePaint.delete();
iconPaint.delete();
dotPaint.delete();
}
}