2076 lines
74 KiB
JavaScript
2076 lines
74 KiB
JavaScript
import * as P from '@react-pdf/primitives';
|
|
import { isNil, matchPercent } from '@react-pdf/fns';
|
|
import absPath from 'abs-svg-path';
|
|
import parsePath from 'parse-svg-path';
|
|
import normalizePath from 'normalize-svg-path';
|
|
import colorString from 'color-string';
|
|
|
|
const renderPath = (ctx, node) => {
|
|
const d = node.props?.d;
|
|
if (d)
|
|
ctx.path(node.props.d);
|
|
};
|
|
|
|
const KAPPA$3 = 4.0 * ((Math.sqrt(2) - 1.0) / 3.0);
|
|
const renderRect = (ctx, node) => {
|
|
const x = node.props?.x || 0;
|
|
const y = node.props?.y || 0;
|
|
const rx = node.props?.rx || 0;
|
|
const ry = node.props?.ry || 0;
|
|
const width = node.props?.width || 0;
|
|
const height = node.props?.height || 0;
|
|
if (!width || !height)
|
|
return;
|
|
if (rx && ry) {
|
|
const krx = rx * KAPPA$3;
|
|
const kry = ry * KAPPA$3;
|
|
ctx.moveTo(x + rx, y);
|
|
ctx.lineTo(x - rx + width, y);
|
|
ctx.bezierCurveTo(x - rx + width + krx, y, x + width, y + ry - kry, x + width, y + ry);
|
|
ctx.lineTo(x + width, y + height - ry);
|
|
ctx.bezierCurveTo(x + width, y + height - ry + kry, x - rx + width + krx, y + height, x - rx + width, y + height);
|
|
ctx.lineTo(x + rx, y + height);
|
|
ctx.bezierCurveTo(x + rx - krx, y + height, x, y + height - ry + kry, x, y + height - ry);
|
|
ctx.lineTo(x, y + ry);
|
|
ctx.bezierCurveTo(x, y + ry - kry, x + rx - krx, y, x + rx, y);
|
|
}
|
|
else {
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + width, y);
|
|
ctx.lineTo(x + width, y + height);
|
|
ctx.lineTo(x, y + height);
|
|
}
|
|
ctx.closePath();
|
|
};
|
|
|
|
const renderLine$1 = (ctx, node) => {
|
|
const { x1, x2, y1, y2 } = node.props || {};
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
};
|
|
|
|
const renderGroup = () => {
|
|
// noop
|
|
};
|
|
|
|
const KAPPA$2 = 4.0 * ((Math.sqrt(2) - 1.0) / 3.0);
|
|
const drawEllipse = (ctx, rx, ry, cx = 0, cy = 0) => {
|
|
const x = cx - rx;
|
|
const y = cy - ry;
|
|
const ox = rx * KAPPA$2;
|
|
const oy = ry * KAPPA$2;
|
|
const xe = x + rx * 2;
|
|
const ye = y + ry * 2;
|
|
const xm = x + rx;
|
|
const ym = y + ry;
|
|
ctx.moveTo(x, ym);
|
|
ctx.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y);
|
|
ctx.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym);
|
|
ctx.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye);
|
|
ctx.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym);
|
|
ctx.closePath();
|
|
};
|
|
const renderEllipse = (ctx, node) => {
|
|
const { cx, cy, rx, ry } = node.props || {};
|
|
drawEllipse(ctx, rx, ry, cx, cy);
|
|
};
|
|
|
|
const renderCircle = (ctx, node) => {
|
|
const cx = node.props?.cx;
|
|
const cy = node.props?.cy;
|
|
const r = node.props?.r;
|
|
drawEllipse(ctx, r, r, cx, cy);
|
|
};
|
|
|
|
/* eslint-disable no-return-assign */
|
|
const number = (n) => {
|
|
if (n > -1e21 && n < 1e21) {
|
|
return Math.round(n * 1e6) / 1e6;
|
|
}
|
|
throw new Error(`unsupported number: ${n}`);
|
|
};
|
|
const _renderGlyphs = (ctx, encoded, positions, x, y) => {
|
|
const commands = [];
|
|
const scale = ctx._fontSize / 1000;
|
|
let i;
|
|
let last = 0;
|
|
let hadOffset = false;
|
|
ctx.save();
|
|
// flip coordinate system
|
|
ctx.transform(1, 0, 0, -1, 0, ctx.page.height);
|
|
y = ctx.page.height - y;
|
|
// add current font to page if necessary
|
|
if (ctx.page.fonts[ctx._font.id] == null) {
|
|
ctx.page.fonts[ctx._font.id] = ctx._font.ref();
|
|
}
|
|
// begin the text object
|
|
ctx.addContent('BT');
|
|
// text position
|
|
ctx.addContent(`1 0 0 1 ${number(x)} ${number(y)} Tm`);
|
|
// font and font size
|
|
ctx.addContent(`/${ctx._font.id} ${number(ctx._fontSize)} Tf`);
|
|
// Adds a segment of text to the TJ command buffer
|
|
const addSegment = (cur) => {
|
|
if (last < cur) {
|
|
const hex = encoded.slice(last, cur).join('');
|
|
const advance = positions[cur - 1].xAdvance - positions[cur - 1].advanceWidth;
|
|
commands.push(`<${hex}> ${number(-advance)}`);
|
|
}
|
|
return (last = cur);
|
|
};
|
|
// Flushes the current TJ commands to the output stream
|
|
const flush = (s) => {
|
|
addSegment(s);
|
|
if (commands.length > 0) {
|
|
ctx.addContent(`[${commands.join(' ')}] TJ`);
|
|
return (commands.length = 0);
|
|
}
|
|
};
|
|
for (i = 0; i < positions.length; i += 1) {
|
|
// If we have an x or y offset, we have to break out of the current TJ command
|
|
// so we can move the text position.
|
|
const pos = positions[i];
|
|
if (pos.xOffset || pos.yOffset) {
|
|
// Flush the current buffer
|
|
flush(i);
|
|
// Move the text position and flush just the current character
|
|
ctx.addContent(`1 0 0 1 ${number(x + pos.xOffset * scale)} ${number(y + pos.yOffset * scale)} Tm`);
|
|
flush(i + 1);
|
|
hadOffset = true;
|
|
}
|
|
else {
|
|
// If the last character had an offset, reset the text position
|
|
if (hadOffset) {
|
|
ctx.addContent(`1 0 0 1 ${number(x)} ${number(y)} Tm`);
|
|
hadOffset = false;
|
|
}
|
|
// Group segments that don't have any advance adjustments
|
|
if (pos.xAdvance - pos.advanceWidth !== 0) {
|
|
addSegment(i + 1);
|
|
}
|
|
}
|
|
x += pos.xAdvance * scale;
|
|
}
|
|
// Flush any remaining commands
|
|
flush(i);
|
|
// end the text object
|
|
ctx.addContent('ET');
|
|
// restore flipped coordinate system
|
|
return ctx.restore();
|
|
};
|
|
const renderGlyphs = (ctx, glyphs, positions, x, y) => {
|
|
const scale = 1000 / ctx._fontSize;
|
|
const unitsPerEm = ctx._font.font.unitsPerEm || 1000;
|
|
const advanceWidthScale = 1000 / unitsPerEm;
|
|
// Glyph encoding and positioning
|
|
const encodedGlyphs = ctx._font.encodeGlyphs(glyphs);
|
|
const encodedPositions = positions.map((pos, i) => ({
|
|
xAdvance: pos.xAdvance * scale,
|
|
yAdvance: pos.yAdvance * scale,
|
|
xOffset: pos.xOffset,
|
|
yOffset: pos.yOffset,
|
|
advanceWidth: glyphs[i].advanceWidth * advanceWidthScale,
|
|
}));
|
|
return _renderGlyphs(ctx, encodedGlyphs, encodedPositions, x, y);
|
|
};
|
|
|
|
const renderRun$1 = (ctx, run) => {
|
|
if (!run.glyphs)
|
|
return;
|
|
if (!run.positions)
|
|
return;
|
|
const runAdvanceWidth = run.xAdvance;
|
|
const font = run.attributes.font?.[0];
|
|
const { fontSize, color, opacity } = run.attributes;
|
|
if (color)
|
|
ctx.fillColor(color);
|
|
ctx.fillOpacity(opacity);
|
|
if (font) {
|
|
ctx.font(font.type === 'STANDARD' ? font.fullName : font, fontSize);
|
|
}
|
|
try {
|
|
renderGlyphs(ctx, run.glyphs, run.positions, 0, 0);
|
|
}
|
|
catch (error) {
|
|
console.log(error);
|
|
}
|
|
ctx.translate(runAdvanceWidth, 0);
|
|
};
|
|
const renderSpan = (ctx, line, textAnchor, dominantBaseline) => {
|
|
ctx.save();
|
|
const x = line.box?.x || 0;
|
|
const y = line.box?.y || 0;
|
|
const font = line.runs[0]?.attributes.font?.[0];
|
|
const scale = line.runs[0]?.attributes?.scale || 1;
|
|
const width = line.xAdvance;
|
|
if (!font)
|
|
return;
|
|
const ascent = font.ascent * scale;
|
|
const xHeight = font.xHeight * scale;
|
|
const descent = font.descent * scale;
|
|
const capHeight = font.capHeight * scale;
|
|
let xTranslate = x;
|
|
let yTranslate = y;
|
|
switch (textAnchor) {
|
|
case 'middle':
|
|
xTranslate = x - width / 2;
|
|
break;
|
|
case 'end':
|
|
xTranslate = x - width;
|
|
break;
|
|
default:
|
|
xTranslate = x;
|
|
break;
|
|
}
|
|
switch (dominantBaseline) {
|
|
case 'middle':
|
|
case 'central':
|
|
yTranslate = y + capHeight / 2;
|
|
break;
|
|
case 'hanging':
|
|
yTranslate = y + capHeight;
|
|
break;
|
|
case 'mathematical':
|
|
yTranslate = y + xHeight;
|
|
break;
|
|
case 'text-after-edge':
|
|
yTranslate = y + descent;
|
|
break;
|
|
case 'text-before-edge':
|
|
yTranslate = y + ascent;
|
|
break;
|
|
default:
|
|
yTranslate = y;
|
|
break;
|
|
}
|
|
ctx.translate(xTranslate, yTranslate);
|
|
line.runs.forEach((run) => renderRun$1(ctx, run));
|
|
ctx.restore();
|
|
};
|
|
const renderSvgText = (ctx, node) => {
|
|
const children = node.children;
|
|
children.forEach((span) => renderSpan(ctx, span.lines[0], span.props.textAnchor, span.props.dominantBaseline));
|
|
};
|
|
|
|
const pairs = (values) => {
|
|
const result = [];
|
|
for (let i = 0; i < values.length; i += 2) {
|
|
result.push([values[i], values[i + 1]]);
|
|
}
|
|
return result;
|
|
};
|
|
/**
|
|
* Parse svg-like points into number arrays
|
|
*
|
|
* @param points string ex. "20,30 50,60"
|
|
* @returns points array ex. [[20, 30], [50, 60]]
|
|
*/
|
|
const parsePoints = (points) => {
|
|
let values = (points || '')
|
|
.trim()
|
|
.replace(/,/g, ' ')
|
|
.replace(/(\d)-(\d)/g, '$1 -$2')
|
|
.split(/\s+/);
|
|
if (values.length % 2 !== 0) {
|
|
values = values.slice(0, -1);
|
|
}
|
|
const mappedValues = values.map(parseFloat);
|
|
return pairs(mappedValues);
|
|
};
|
|
|
|
const drawPolyline = (ctx, points) => {
|
|
if (points.length > 0) {
|
|
ctx.moveTo(points[0][0], points[0][1]);
|
|
points.slice(1).forEach((p) => ctx.lineTo(p[0], p[1]));
|
|
}
|
|
};
|
|
const renderPolyline = (ctx, node) => {
|
|
const points = parsePoints(node.props.points || '');
|
|
drawPolyline(ctx, points);
|
|
};
|
|
|
|
const renderPolygon = (ctx, node) => {
|
|
const points = parsePoints(node.props.points || '');
|
|
drawPolyline(ctx, points);
|
|
ctx.closePath();
|
|
};
|
|
|
|
const renderImage$1 = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
if (!node.image?.data)
|
|
return;
|
|
const { x = 0, y = 0 } = node.props;
|
|
const { width, height, opacity } = node.style;
|
|
const paddingTop = node.box.paddingLeft || 0;
|
|
const paddingLeft = node.box.paddingLeft || 0;
|
|
if (width === 0 || height === 0) {
|
|
console.warn(`Image with src '${node.props.href}' skipped due to invalid dimensions`);
|
|
return;
|
|
}
|
|
if (typeof width === 'string' || typeof height === 'string') {
|
|
console.warn(`Image with src '${node.props.href}' skipped due to percentage width or height`);
|
|
return;
|
|
}
|
|
ctx.save();
|
|
ctx
|
|
.fillOpacity(opacity || 1)
|
|
.image(node.image.data, x + paddingLeft, y + paddingTop, {
|
|
width,
|
|
height,
|
|
});
|
|
ctx.restore();
|
|
};
|
|
|
|
// This constant is used to approximate a symmetrical arc using a cubic
|
|
// Bezier curve.
|
|
const KAPPA$1 = 4.0 * ((Math.sqrt(2) - 1.0) / 3.0);
|
|
const clipNode = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
if (!node.style)
|
|
return;
|
|
const { top, left, width, height } = node.box;
|
|
const { borderTopLeftRadius = 0, borderTopRightRadius = 0, borderBottomRightRadius = 0, borderBottomLeftRadius = 0, } = node.style;
|
|
// Border top
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const rtr = Math.min(borderTopRightRadius, 0.5 * width, 0.5 * height);
|
|
const ctr = rtr * (1.0 - KAPPA$1);
|
|
ctx.moveTo(left + rtr, top);
|
|
ctx.lineTo(left + width - rtr, top);
|
|
ctx.bezierCurveTo(left + width - ctr, top, left + width, top + ctr, left + width, top + rtr);
|
|
// Border right
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const rbr = Math.min(borderBottomRightRadius, 0.5 * width, 0.5 * height);
|
|
const cbr = rbr * (1.0 - KAPPA$1);
|
|
ctx.lineTo(left + width, top + height - rbr);
|
|
ctx.bezierCurveTo(left + width, top + height - cbr, left + width - cbr, top + height, left + width - rbr, top + height);
|
|
// Border bottom
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const rbl = Math.min(borderBottomLeftRadius, 0.5 * width, 0.5 * height);
|
|
const cbl = rbl * (1.0 - KAPPA$1);
|
|
ctx.lineTo(left + rbl, top + height);
|
|
ctx.bezierCurveTo(left + cbl, top + height, left, top + height - cbl, left, top + height - rbl);
|
|
// Border left
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const rtl = Math.min(borderTopLeftRadius, 0.5 * width, 0.5 * height);
|
|
const ctl = rtl * (1.0 - KAPPA$1);
|
|
ctx.lineTo(left, top + rtl);
|
|
ctx.bezierCurveTo(left, top + ctl, left + ctl, top, left + rtl, top);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
};
|
|
|
|
const applySingleTransformation = (ctx, transform, origin) => {
|
|
const { operation, value } = transform;
|
|
switch (operation) {
|
|
case 'scale': {
|
|
const [scaleX, scaleY] = value;
|
|
ctx.scale(scaleX, scaleY, { origin });
|
|
break;
|
|
}
|
|
case 'rotate': {
|
|
const [angle] = value;
|
|
ctx.rotate(angle, { origin });
|
|
break;
|
|
}
|
|
case 'translate': {
|
|
const [x, y = 0] = value;
|
|
ctx.translate(x, y, { origin });
|
|
break;
|
|
}
|
|
case 'skew': {
|
|
const [xAngle = 0, yAngle = 0] = value;
|
|
const radx = (xAngle * Math.PI) / 180;
|
|
const rady = (yAngle * Math.PI) / 180;
|
|
const tanx = Math.tan(radx);
|
|
const tany = Math.tan(rady);
|
|
let x = 0;
|
|
let y = 0;
|
|
if (origin != null) {
|
|
[x, y] = Array.from(origin);
|
|
const x1 = x + tanx * y;
|
|
const y1 = y + tany * x;
|
|
x -= x1;
|
|
y -= y1;
|
|
}
|
|
ctx.transform(1, tany, tanx, 1, x, y);
|
|
break;
|
|
}
|
|
case 'matrix': {
|
|
ctx.transform(...value);
|
|
break;
|
|
}
|
|
default: {
|
|
console.error(`Transform operation: '${operation}' doesn't supported`);
|
|
}
|
|
}
|
|
};
|
|
const applyTransformations = (ctx, node) => {
|
|
if (!node.origin)
|
|
return;
|
|
const { props, style } = node;
|
|
const origin = [node.origin.left, node.origin.top];
|
|
const propsTransform = 'transform' in props ? props.transform : undefined;
|
|
const operations = style?.transform || propsTransform || [];
|
|
operations.forEach((operation) => {
|
|
applySingleTransformation(ctx, operation, origin);
|
|
});
|
|
};
|
|
|
|
// From https://github.com/dy/svg-path-bounds/blob/master/index.js
|
|
const getPathBoundingBox = (node) => {
|
|
const path = normalizePath(absPath(parsePath(node.props?.d || '')));
|
|
if (!path.length)
|
|
return [0, 0, 0, 0];
|
|
const bounds = [Infinity, Infinity, -Infinity, -Infinity];
|
|
for (let i = 0, l = path.length; i < l; i += 1) {
|
|
const points = path[i].slice(1);
|
|
for (let j = 0; j < points.length; j += 2) {
|
|
if (points[j + 0] < bounds[0])
|
|
bounds[0] = points[j + 0];
|
|
if (points[j + 1] < bounds[1])
|
|
bounds[1] = points[j + 1];
|
|
if (points[j + 0] > bounds[2])
|
|
bounds[2] = points[j + 0];
|
|
if (points[j + 1] > bounds[3])
|
|
bounds[3] = points[j + 1];
|
|
}
|
|
}
|
|
return bounds;
|
|
};
|
|
const getCircleBoundingBox = (node) => {
|
|
const r = node.props?.r || 0;
|
|
const cx = node.props?.cx || 0;
|
|
const cy = node.props?.cy || 0;
|
|
return [cx - r, cy - r, cx + r, cy + r];
|
|
};
|
|
const getEllipseBoundingBox = (node) => {
|
|
const cx = node.props?.cx || 0;
|
|
const cy = node.props?.cy || 0;
|
|
const rx = node.props?.rx || 0;
|
|
const ry = node.props?.ry || 0;
|
|
return [cx - rx, cy - ry, cx + rx, cy + ry];
|
|
};
|
|
const getLineBoundingBox = (node) => {
|
|
const x1 = node.props?.x1 || 0;
|
|
const y1 = node.props?.y1 || 0;
|
|
const x2 = node.props?.x2 || 0;
|
|
const y2 = node.props?.y2 || 0;
|
|
return [
|
|
Math.min(x1, x2),
|
|
Math.min(y1, y2),
|
|
Math.max(x1, x2),
|
|
Math.max(y1, y2),
|
|
];
|
|
};
|
|
const getRectBoundingBox = (node) => {
|
|
const x = node.props?.x || 0;
|
|
const y = node.props?.y || 0;
|
|
const width = node.props?.width || 0;
|
|
const height = node.props?.height || 0;
|
|
return [x, y, x + width, y + height];
|
|
};
|
|
const max = (values) => Math.max(-Infinity, ...values);
|
|
const min = (values) => Math.min(Infinity, ...values);
|
|
const getPolylineBoundingBox = (node) => {
|
|
const points = parsePoints(node.props?.points);
|
|
const xValues = points.map((p) => p[0]);
|
|
const yValues = points.map((p) => p[1]);
|
|
return [min(xValues), min(yValues), max(xValues), max(yValues)];
|
|
};
|
|
const boundingBoxFns = {
|
|
[P.Rect]: getRectBoundingBox,
|
|
[P.Line]: getLineBoundingBox,
|
|
[P.Path]: getPathBoundingBox,
|
|
[P.Circle]: getCircleBoundingBox,
|
|
[P.Ellipse]: getEllipseBoundingBox,
|
|
[P.Polygon]: getPolylineBoundingBox,
|
|
[P.Polyline]: getPolylineBoundingBox,
|
|
};
|
|
const getBoundingBox = (node) => {
|
|
const boundingBoxFn = boundingBoxFns[node.type];
|
|
return boundingBoxFn ? boundingBoxFn(node) : [0, 0, 0, 0];
|
|
};
|
|
|
|
const setStrokeWidth = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('strokeWidth' in node.props))
|
|
return;
|
|
const lineWidth = node.props.strokeWidth;
|
|
if (lineWidth)
|
|
ctx.lineWidth(lineWidth);
|
|
};
|
|
const setStrokeColor = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('stroke' in node.props))
|
|
return;
|
|
const strokeColor = node.props.stroke;
|
|
if (strokeColor)
|
|
ctx.strokeColor(strokeColor);
|
|
};
|
|
const setOpacity = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('opacity' in node.props))
|
|
return;
|
|
const opacity = node.props.opacity;
|
|
if (!isNil(opacity))
|
|
ctx.opacity(opacity);
|
|
};
|
|
const setFillOpacity = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('fillOpacity' in node.props))
|
|
return;
|
|
const fillOpacity = node.props.fillOpacity || null;
|
|
if (!isNil(fillOpacity))
|
|
ctx.fillOpacity(fillOpacity);
|
|
};
|
|
const setStrokeOpacity = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('strokeOpacity' in node.props))
|
|
return;
|
|
const strokeOpacity = node.props?.strokeOpacity;
|
|
if (!isNil(strokeOpacity))
|
|
ctx.strokeOpacity(strokeOpacity);
|
|
};
|
|
const setLineJoin = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('strokeLinejoin' in node.props))
|
|
return;
|
|
const lineJoin = node.props.strokeLinejoin;
|
|
if (lineJoin)
|
|
ctx.lineJoin(lineJoin);
|
|
};
|
|
const setLineCap = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('strokeLinecap' in node.props))
|
|
return;
|
|
const lineCap = node.props?.strokeLinecap;
|
|
if (lineCap)
|
|
ctx.lineCap(lineCap);
|
|
};
|
|
const setLineDash = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('strokeDasharray' in node.props))
|
|
return;
|
|
const value = node.props?.strokeDasharray || null;
|
|
// @ts-expect-error check this works as expected
|
|
if (value)
|
|
ctx.dash(value.split(/[\s,]+/).map(Number));
|
|
};
|
|
const hasLinearGradientFill = (node) => {
|
|
if (!node.props)
|
|
return false;
|
|
if (!('fill' in node.props))
|
|
return false;
|
|
if (typeof node.props.fill === 'string')
|
|
return false;
|
|
return node.props.fill?.type === P.LinearGradient;
|
|
};
|
|
const hasRadialGradientFill = (node) => {
|
|
if (!node.props)
|
|
return false;
|
|
if (!('fill' in node.props))
|
|
return false;
|
|
if (typeof node.props.fill === 'string')
|
|
return false;
|
|
return node.props.fill?.type === P.RadialGradient;
|
|
};
|
|
function multiplyMatrices(m1, m2) {
|
|
const a = m1[0] * m2[0] + m1[2] * m2[1];
|
|
const b = m1[1] * m2[0] + m1[3] * m2[1];
|
|
const c = m1[0] * m2[2] + m1[2] * m2[3];
|
|
const d = m1[1] * m2[2] + m1[3] * m2[3];
|
|
const e = m1[0] * m2[4] + m1[2] * m2[5] + m1[4];
|
|
const f = m1[1] * m2[4] + m1[3] * m2[5] + m1[5];
|
|
return [a, b, c, d, e, f];
|
|
}
|
|
const transformGradient = (grad, transforms, bbox, units) => {
|
|
const matrices = transforms.map((transform) => {
|
|
switch (transform.operation) {
|
|
case 'scale': {
|
|
const value = transform.value;
|
|
return [value[0], 0, 0, value[1], 0, 0];
|
|
}
|
|
case 'translate': {
|
|
const value = transform.value;
|
|
let x = value[0] || 0;
|
|
let y = value[1] || 0;
|
|
if (units === 'objectBoundingBox') {
|
|
x = (bbox[2] - bbox[0]) * x;
|
|
y = (bbox[3] - bbox[1]) * y;
|
|
}
|
|
return [1, 0, 0, 1, x, y];
|
|
}
|
|
case 'rotate': {
|
|
const value = transform.value;
|
|
const cos = Math.cos(value[0]);
|
|
const sin = Math.sin(value[0]);
|
|
return [cos, sin, -sin, cos, 0, 0];
|
|
}
|
|
case 'skew': {
|
|
const value = transform.value;
|
|
return [1, Math.tan(value[0]), Math.tan(value[1]), 1, 0, 0];
|
|
}
|
|
case 'matrix': {
|
|
const value = transform.value;
|
|
let x = value[4] || 0;
|
|
let y = value[5] || 0;
|
|
if (units === 'objectBoundingBox') {
|
|
x = (bbox[2] - bbox[0]) * x;
|
|
y = (bbox[3] - bbox[1]) * y;
|
|
}
|
|
return [value[0], value[1], value[2], value[3], x, y];
|
|
}
|
|
default:
|
|
return [1, 0, 0, 1, 0, 0];
|
|
}
|
|
});
|
|
const matrix = matrices.reduce(multiplyMatrices, [1, 0, 0, 1, 0, 0]);
|
|
grad.setTransform(...matrix);
|
|
};
|
|
// Math simplified from https://github.com/devongovett/svgkit/blob/master/src/elements/SVGGradient.js#L104
|
|
const setLinearGradientFill = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('fill' in node.props))
|
|
return;
|
|
const bbox = getBoundingBox(node);
|
|
const gradient = node.props?.fill;
|
|
if (!gradient)
|
|
return;
|
|
const units = gradient.props.gradientUnits || 'objectBoundingBox';
|
|
const transforms = gradient.props.gradientTransform || [];
|
|
let x1 = gradient.props.x1 || 0;
|
|
let y1 = gradient.props.y1 || 0;
|
|
let x2 = gradient.props.x2 || 1;
|
|
let y2 = gradient.props.y2 || 0;
|
|
if (units === 'objectBoundingBox') {
|
|
const m0 = bbox[2] - bbox[0];
|
|
const m3 = bbox[3] - bbox[1];
|
|
const m4 = bbox[0];
|
|
const m5 = bbox[1];
|
|
x1 = m0 * x1 + m4;
|
|
y1 = m3 * y1 + m5;
|
|
x2 = m0 * x2 + m4;
|
|
y2 = m3 * y2 + m5;
|
|
}
|
|
const grad = ctx.linearGradient(x1, y1, x2, y2);
|
|
transformGradient(grad, transforms, bbox, units);
|
|
gradient.children?.forEach((stop) => {
|
|
grad.stop(stop.props.offset, stop.props.stopColor, stop.props.stopOpacity);
|
|
});
|
|
ctx.fill(grad);
|
|
};
|
|
// Math simplified from https://github.com/devongovett/svgkit/blob/master/src/elements/SVGGradient.js#L155
|
|
const setRadialGradientFill = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('fill' in node.props))
|
|
return;
|
|
const bbox = getBoundingBox(node);
|
|
const gradient = node.props?.fill;
|
|
if (!gradient)
|
|
return;
|
|
const units = gradient.props.gradientUnits || 'objectBoundingBox';
|
|
const transforms = gradient.props.gradientTransform || [];
|
|
let r = gradient.props.r || 0.5;
|
|
let cx = gradient.props.cx || 0.5;
|
|
let cy = gradient.props.cy || 0.5;
|
|
let fx = gradient.props.fx || cx;
|
|
let fy = gradient.props.fy || cy;
|
|
if (units === 'objectBoundingBox') {
|
|
const m0 = bbox[2] - bbox[0];
|
|
const m3 = bbox[3] - bbox[1];
|
|
const m4 = bbox[0];
|
|
const m5 = bbox[1];
|
|
r = r * m0;
|
|
cx = m0 * cx + m4;
|
|
cy = m3 * cy + m5;
|
|
fx = m0 * fx + m4;
|
|
fy = m3 * fy + m5;
|
|
}
|
|
const grad = ctx.radialGradient(cx, cy, 0, fx, fy, r);
|
|
transformGradient(grad, transforms, bbox, units);
|
|
gradient.children?.forEach((stop) => {
|
|
grad.stop(stop.props.offset, stop.props.stopColor, stop.props.stopOpacity);
|
|
});
|
|
ctx.fill(grad);
|
|
};
|
|
const setFillColor = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('fill' in node.props))
|
|
return;
|
|
const fillColor = node.props?.fill;
|
|
if (fillColor)
|
|
ctx.fillColor(fillColor);
|
|
};
|
|
const setFill = (ctx, node) => {
|
|
if (hasLinearGradientFill(node))
|
|
return setLinearGradientFill(ctx, node);
|
|
if (hasRadialGradientFill(node))
|
|
return setRadialGradientFill(ctx, node);
|
|
return setFillColor(ctx, node);
|
|
};
|
|
const draw = (ctx, node) => {
|
|
const props = node.props || {};
|
|
if ('fill' in props && 'stroke' in props && props.fill && props.stroke) {
|
|
ctx.fillAndStroke(props.fillRule);
|
|
}
|
|
else if ('fill' in props && props.fill) {
|
|
ctx.fill(props.fillRule);
|
|
}
|
|
else if ('stroke' in props && props.stroke) {
|
|
ctx.stroke();
|
|
}
|
|
else {
|
|
ctx.save();
|
|
ctx.opacity(0);
|
|
ctx.fill(null);
|
|
ctx.restore();
|
|
}
|
|
};
|
|
const noop = () => { };
|
|
const renderFns$1 = {
|
|
[P.Tspan]: noop,
|
|
[P.TextInstance]: noop,
|
|
[P.Path]: renderPath,
|
|
[P.Rect]: renderRect,
|
|
[P.Line]: renderLine$1,
|
|
[P.G]: renderGroup,
|
|
[P.Text]: renderSvgText,
|
|
[P.Circle]: renderCircle,
|
|
[P.Image]: renderImage$1,
|
|
[P.Ellipse]: renderEllipse,
|
|
[P.Polygon]: renderPolygon,
|
|
[P.Polyline]: renderPolyline,
|
|
};
|
|
const renderNode$1 = (ctx, node) => {
|
|
const renderFn = renderFns$1[node.type];
|
|
if (renderFn) {
|
|
renderFn(ctx, node);
|
|
}
|
|
else {
|
|
console.warn(`SVG node of type ${node.type} is not currently supported`);
|
|
}
|
|
};
|
|
const drawNode = (ctx, node) => {
|
|
setLineCap(ctx, node);
|
|
setLineDash(ctx, node);
|
|
setLineJoin(ctx, node);
|
|
setStrokeWidth(ctx, node);
|
|
setStrokeColor(ctx, node);
|
|
setFill(ctx, node);
|
|
setStrokeOpacity(ctx, node);
|
|
setFillOpacity(ctx, node);
|
|
setOpacity(ctx, node);
|
|
applyTransformations(ctx, node);
|
|
renderNode$1(ctx, node);
|
|
draw(ctx, node);
|
|
};
|
|
const clipPath = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('clipPath' in node.props))
|
|
return;
|
|
const value = node.props.clipPath;
|
|
if (value) {
|
|
const children = value.children || [];
|
|
children.forEach((child) => renderNode$1(ctx, child));
|
|
ctx.clip();
|
|
}
|
|
};
|
|
const drawChildren = (ctx, node) => {
|
|
const children = node.children || [];
|
|
children.forEach((child) => {
|
|
ctx.save();
|
|
clipPath(ctx, child);
|
|
drawNode(ctx, child);
|
|
drawChildren(ctx, child);
|
|
ctx.restore();
|
|
});
|
|
};
|
|
const resolveAspectRatio = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { width, height } = node.box;
|
|
const { viewBox, preserveAspectRatio } = node.props;
|
|
const { meetOrSlice = 'meet', align = 'xMidYMid' } = preserveAspectRatio || {};
|
|
if (viewBox == null || width == null || height == null)
|
|
return;
|
|
const x = viewBox?.minX || 0;
|
|
const y = viewBox?.minY || 0;
|
|
const logicalWidth = viewBox?.maxX || width;
|
|
const logicalHeight = viewBox?.maxY || height;
|
|
const logicalRatio = logicalWidth / logicalHeight;
|
|
const physicalRatio = width / height;
|
|
const scaleX = width / logicalWidth;
|
|
const scaleY = height / logicalHeight;
|
|
if (align === 'none') {
|
|
ctx.scale(scaleX, scaleY);
|
|
ctx.translate(-x, -y);
|
|
return;
|
|
}
|
|
if ((logicalRatio < physicalRatio && meetOrSlice === 'meet') ||
|
|
(logicalRatio >= physicalRatio && meetOrSlice === 'slice')) {
|
|
ctx.scale(scaleY, scaleY);
|
|
switch (align) {
|
|
case 'xMinYMin':
|
|
case 'xMinYMid':
|
|
case 'xMinYMax':
|
|
ctx.translate(-x, -y);
|
|
break;
|
|
case 'xMidYMin':
|
|
case 'xMidYMid':
|
|
case 'xMidYMax':
|
|
ctx.translate(-x - (logicalWidth - (width * logicalHeight) / height) / 2, -y);
|
|
break;
|
|
default:
|
|
ctx.translate(-x - (logicalWidth - (width * logicalHeight) / height), -y);
|
|
}
|
|
}
|
|
else {
|
|
ctx.scale(scaleX, scaleX);
|
|
switch (align) {
|
|
case 'xMinYMin':
|
|
case 'xMidYMin':
|
|
case 'xMaxYMin':
|
|
ctx.translate(-x, -y);
|
|
break;
|
|
case 'xMinYMid':
|
|
case 'xMidYMid':
|
|
case 'xMaxYMid':
|
|
ctx.translate(-x, -y - (logicalHeight - (height * logicalWidth) / width) / 2);
|
|
break;
|
|
default:
|
|
ctx.translate(-x, -y - (logicalHeight - (height * logicalWidth) / width));
|
|
}
|
|
}
|
|
};
|
|
const moveToOrigin = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left } = node.box;
|
|
const paddingLeft = node.box.paddingLeft || 0;
|
|
const paddingTop = node.box.paddingTop || 0;
|
|
ctx.translate(left + paddingLeft, top + paddingTop);
|
|
};
|
|
const renderSvg = (ctx, node) => {
|
|
ctx.save();
|
|
clipNode(ctx, node);
|
|
moveToOrigin(ctx, node);
|
|
resolveAspectRatio(ctx, node);
|
|
drawChildren(ctx, node);
|
|
ctx.restore();
|
|
};
|
|
|
|
const black = { value: '#000', opacity: 1 };
|
|
// TODO: parse to number[] in layout to avoid this step
|
|
const parseColor = (hex) => {
|
|
if (!hex)
|
|
return black;
|
|
const parsed = colorString.get(hex);
|
|
if (!parsed)
|
|
return black;
|
|
const value = colorString.to.hex(parsed.value.slice(0, 3));
|
|
const opacity = parsed.value[3];
|
|
return { value, opacity };
|
|
};
|
|
|
|
const DEST_REGEXP = /^#.+/;
|
|
const isSrcId$1 = (src) => src.match(DEST_REGEXP);
|
|
const renderAttachment = (ctx, attachment) => {
|
|
const { xOffset = 0, yOffset = 0, width, height, image } = attachment;
|
|
ctx.translate(-width + xOffset, -height + yOffset);
|
|
ctx.image(image, 0, 0, {
|
|
fit: [width, height],
|
|
align: 'center',
|
|
valign: 'bottom',
|
|
});
|
|
};
|
|
const renderAttachments = (ctx, run) => {
|
|
if (!run.glyphs)
|
|
return;
|
|
if (!run.positions)
|
|
return;
|
|
const font = run.attributes.font?.[0];
|
|
if (!font)
|
|
return;
|
|
ctx.save();
|
|
const space = font.glyphForCodePoint(0x20);
|
|
const objectReplacement = font.glyphForCodePoint(0xfffc);
|
|
let attachmentAdvance = 0;
|
|
for (let i = 0; i < run.glyphs.length; i += 1) {
|
|
const position = run.positions[i];
|
|
const glyph = run.glyphs[i];
|
|
attachmentAdvance += position.xAdvance || 0;
|
|
if (glyph.id === objectReplacement.id && run.attributes.attachment) {
|
|
ctx.translate(attachmentAdvance, position.yOffset || 0);
|
|
renderAttachment(ctx, run.attributes.attachment);
|
|
run.glyphs[i] = space;
|
|
attachmentAdvance = 0;
|
|
}
|
|
}
|
|
ctx.restore();
|
|
};
|
|
const renderRun = (ctx, run) => {
|
|
if (!run.glyphs)
|
|
return;
|
|
if (!run.positions)
|
|
return;
|
|
const font = run.attributes.font?.[0];
|
|
if (!font)
|
|
return;
|
|
const { fontSize, link } = run.attributes;
|
|
const color = parseColor(run.attributes.color);
|
|
const opacity = isNil(run.attributes.opacity)
|
|
? color.opacity
|
|
: run.attributes.opacity;
|
|
const { height = 0, descent = 0, xAdvance = 0 } = run;
|
|
ctx.fillColor(color.value);
|
|
ctx.fillOpacity(opacity);
|
|
if (link) {
|
|
if (isSrcId$1(link)) {
|
|
ctx.goTo(0, -height - descent, xAdvance, height, link.slice(1));
|
|
}
|
|
else {
|
|
ctx.link(0, -height - descent, xAdvance, height, link);
|
|
}
|
|
}
|
|
renderAttachments(ctx, run);
|
|
ctx.font(font.type === 'STANDARD' ? font.fullName : font, fontSize);
|
|
try {
|
|
renderGlyphs(ctx, run.glyphs, run.positions, 0, 0);
|
|
}
|
|
catch (error) {
|
|
console.log(error);
|
|
}
|
|
ctx.translate(xAdvance, 0);
|
|
};
|
|
const renderBackground$1 = (ctx, rect, backgroundColor) => {
|
|
const color = parseColor(backgroundColor);
|
|
ctx.save();
|
|
ctx.fillOpacity(color.opacity);
|
|
ctx.rect(rect.x, rect.y, rect.width, rect.height);
|
|
ctx.fill(color.value);
|
|
ctx.restore();
|
|
};
|
|
const renderDecorationLine = (ctx, decorationLine) => {
|
|
ctx.save();
|
|
ctx.lineWidth(decorationLine.rect.height);
|
|
ctx.strokeOpacity(decorationLine.opacity);
|
|
if (/dashed/.test(decorationLine.style)) {
|
|
ctx.dash(3 * decorationLine.rect.height, {});
|
|
}
|
|
else if (/dotted/.test(decorationLine.style)) {
|
|
ctx.dash(decorationLine.rect.height, {});
|
|
}
|
|
if (/wavy/.test(decorationLine.style)) {
|
|
const dist = Math.max(2, decorationLine.rect.height);
|
|
let step = 1.1 * dist;
|
|
const stepCount = Math.floor(decorationLine.rect.width / (2 * step));
|
|
// Adjust step to fill entire width
|
|
const remainingWidth = decorationLine.rect.width - stepCount * 2 * step;
|
|
const adjustment = remainingWidth / stepCount / 2;
|
|
step += adjustment;
|
|
const cp1y = decorationLine.rect.y + dist;
|
|
const cp2y = decorationLine.rect.y - dist;
|
|
let { x } = decorationLine.rect;
|
|
ctx.moveTo(decorationLine.rect.x, decorationLine.rect.y);
|
|
for (let i = 0; i < stepCount; i += 1) {
|
|
ctx.bezierCurveTo(x + step, cp1y, x + step, cp2y, x + 2 * step, decorationLine.rect.y);
|
|
x += 2 * step;
|
|
}
|
|
}
|
|
else {
|
|
ctx.moveTo(decorationLine.rect.x, decorationLine.rect.y);
|
|
ctx.lineTo(decorationLine.rect.x + decorationLine.rect.width, decorationLine.rect.y);
|
|
if (/double/.test(decorationLine.style)) {
|
|
ctx.moveTo(decorationLine.rect.x, decorationLine.rect.y + decorationLine.rect.height * 2);
|
|
ctx.lineTo(decorationLine.rect.x + decorationLine.rect.width, decorationLine.rect.y + decorationLine.rect.height * 2);
|
|
}
|
|
}
|
|
ctx.stroke(decorationLine.color);
|
|
ctx.restore();
|
|
};
|
|
const renderLine = (ctx, line) => {
|
|
if (!line.box)
|
|
return;
|
|
const lineAscent = line.ascent || 0;
|
|
ctx.save();
|
|
ctx.translate(line.box.x, line.box.y + lineAscent);
|
|
for (let i = 0; i < line.runs.length; i += 1) {
|
|
const run = line.runs[i];
|
|
const isLastRun = i === line.runs.length - 1;
|
|
if (run.attributes.backgroundColor) {
|
|
const xAdvance = run.xAdvance ?? 0;
|
|
const overflowRight = isLastRun ? line.overflowRight ?? 0 : 0;
|
|
const backgroundRect = {
|
|
x: 0,
|
|
y: -lineAscent,
|
|
height: line.box.height,
|
|
width: xAdvance - overflowRight,
|
|
};
|
|
renderBackground$1(ctx, backgroundRect, run.attributes.backgroundColor);
|
|
}
|
|
renderRun(ctx, run);
|
|
}
|
|
ctx.restore();
|
|
ctx.save();
|
|
ctx.translate(line.box.x, line.box.y);
|
|
if (line.decorationLines) {
|
|
for (let i = 0; i < line.decorationLines.length; i += 1) {
|
|
const decorationLine = line.decorationLines[i];
|
|
renderDecorationLine(ctx, decorationLine);
|
|
}
|
|
}
|
|
ctx.restore();
|
|
};
|
|
const renderBlock = (ctx, block) => {
|
|
block.forEach((line) => {
|
|
renderLine(ctx, line);
|
|
});
|
|
};
|
|
const renderText = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
if (!node.lines)
|
|
return;
|
|
const { top, left } = node.box;
|
|
const blocks = [node.lines];
|
|
const paddingTop = node.box?.paddingTop || 0;
|
|
const paddingLeft = node.box?.paddingLeft || 0;
|
|
const initialY = node.lines[0] ? node.lines[0].box.y : 0;
|
|
const offsetX = node.alignOffset || 0;
|
|
ctx.save();
|
|
ctx.translate(left + paddingLeft - offsetX, top + paddingTop - initialY);
|
|
blocks.forEach((block) => {
|
|
renderBlock(ctx, block);
|
|
});
|
|
ctx.restore();
|
|
};
|
|
|
|
const renderPage = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { width, height } = node.box;
|
|
const dpi = node.props?.dpi || 72;
|
|
const userUnit = dpi / 72;
|
|
ctx.addPage({ size: [width, height], margin: 0, userUnit });
|
|
};
|
|
|
|
const renderNote = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left } = node.box;
|
|
const value = node?.children?.[0].value || '';
|
|
const color = node.style?.backgroundColor;
|
|
ctx.note(left, top, 0, 0, value, { color });
|
|
};
|
|
|
|
const embedImage = (ctx, node) => {
|
|
const src = node.image.data;
|
|
let image;
|
|
if (typeof src === 'string') {
|
|
image = ctx._imageRegistry[src];
|
|
}
|
|
if (!image) {
|
|
image = ctx.openImage(src);
|
|
}
|
|
if (!image.obj) {
|
|
image.embed(ctx);
|
|
}
|
|
return image;
|
|
};
|
|
|
|
const isNumeric = (n) => {
|
|
return !Number.isNaN(parseFloat(n)) && Number.isFinite(n);
|
|
};
|
|
const applyContainObjectFit = (cw, ch, iw, ih, px, py) => {
|
|
const cr = cw / ch;
|
|
const ir = iw / ih;
|
|
const pxp = matchPercent(px ?? null);
|
|
const pyp = matchPercent(py ?? null);
|
|
const pxv = pxp ? pxp.percent : 0.5;
|
|
const pyv = pyp ? pyp.percent : 0.5;
|
|
if (cr > ir) {
|
|
const height = ch;
|
|
const width = height * ir;
|
|
const yOffset = isNumeric(py) ? py : 0;
|
|
const xOffset = isNumeric(px) ? px : (cw - width) * pxv;
|
|
return { width, height, xOffset, yOffset };
|
|
}
|
|
const width = cw;
|
|
const height = width / ir;
|
|
const xOffset = isNumeric(px) ? px : 0;
|
|
const yOffset = isNumeric(py) ? py : (ch - height) * pyv;
|
|
return { width, height, yOffset, xOffset };
|
|
};
|
|
const applyNoneObjectFit = (cw, ch, iw, ih, px, py) => {
|
|
const width = iw;
|
|
const height = ih;
|
|
const pxp = matchPercent(px ?? null);
|
|
const pyp = matchPercent(py ?? null);
|
|
const pxv = pxp ? pxp.percent : 0.5;
|
|
const pyv = pyp ? pyp.percent : 0.5;
|
|
const xOffset = isNumeric(px) ? px : (cw - width) * pxv;
|
|
const yOffset = isNumeric(py) ? py : (ch - height) * pyv;
|
|
return { width, height, xOffset, yOffset };
|
|
};
|
|
const applyCoverObjectFit = (cw, ch, iw, ih, px, py) => {
|
|
const ir = iw / ih;
|
|
const cr = cw / ch;
|
|
const pxp = matchPercent(px ?? null);
|
|
const pyp = matchPercent(py ?? null);
|
|
const pxv = pxp ? pxp.percent : 0.5;
|
|
const pyv = pyp ? pyp.percent : 0.5;
|
|
if (cr > ir) {
|
|
const width = cw;
|
|
const height = width / ir;
|
|
const xOffset = isNumeric(px) ? px : 0;
|
|
const yOffset = isNumeric(py) ? py : (ch - height) * pyv;
|
|
return { width, height, yOffset, xOffset };
|
|
}
|
|
const height = ch;
|
|
const width = height * ir;
|
|
const xOffset = isNumeric(px) ? px : (cw - width) * pxv;
|
|
const yOffset = isNumeric(py) ? py : 0;
|
|
return { width, height, xOffset, yOffset };
|
|
};
|
|
const applyScaleDownObjectFit = (cw, ch, iw, ih, px, py) => {
|
|
const containDimension = applyContainObjectFit(cw, ch, iw, ih, px, py);
|
|
const noneDimension = applyNoneObjectFit(cw, ch, iw, ih, px, py);
|
|
return containDimension.width < noneDimension.width
|
|
? containDimension
|
|
: noneDimension;
|
|
};
|
|
const applyFillObjectFit = (cw, ch, px, py) => {
|
|
return {
|
|
width: cw,
|
|
height: ch,
|
|
xOffset: matchPercent(px ?? null) ? 0 : px || 0,
|
|
yOffset: matchPercent(py ?? null) ? 0 : py || 0,
|
|
};
|
|
};
|
|
const resolveObjectFit = (type = 'fill', cw, ch, iw, ih, px, py) => {
|
|
switch (type) {
|
|
case 'contain':
|
|
return applyContainObjectFit(cw, ch, iw, ih, px, py);
|
|
case 'cover':
|
|
return applyCoverObjectFit(cw, ch, iw, ih, px, py);
|
|
case 'none':
|
|
return applyNoneObjectFit(cw, ch, iw, ih, px, py);
|
|
case 'scale-down':
|
|
return applyScaleDownObjectFit(cw, ch, iw, ih, px, py);
|
|
default:
|
|
return applyFillObjectFit(cw, ch, px, py);
|
|
}
|
|
};
|
|
|
|
const drawImage = (ctx, node, options) => {
|
|
if (!node.box)
|
|
return;
|
|
if (!node.image)
|
|
return;
|
|
const { left, top } = node.box;
|
|
const opacity = node.style?.opacity;
|
|
const objectFit = node.style?.objectFit;
|
|
const objectPositionX = node.style?.objectPositionX;
|
|
const objectPositionY = node.style?.objectPositionY;
|
|
const paddingTop = node.box.paddingTop || 0;
|
|
const paddingRight = node.box.paddingRight || 0;
|
|
const paddingBottom = node.box.paddingBottom || 0;
|
|
const paddingLeft = node.box.paddingLeft || 0;
|
|
const imageCache = options.imageCache || new Map();
|
|
const { width, height, xOffset, yOffset } = resolveObjectFit(objectFit, node.box.width - paddingLeft - paddingRight, node.box.height - paddingTop - paddingBottom, node.image.width, node.image.height, objectPositionX, objectPositionY);
|
|
if (node.image.data) {
|
|
if (width !== 0 && height !== 0) {
|
|
const cacheKey = node.image.key;
|
|
const image = imageCache.get(cacheKey) || embedImage(ctx, node);
|
|
if (cacheKey)
|
|
imageCache.set(cacheKey, image);
|
|
const imageOpacity = isNil(opacity) ? 1 : opacity;
|
|
ctx
|
|
.fillOpacity(imageOpacity)
|
|
.image(image, left + paddingLeft + xOffset, top + paddingTop + yOffset, {
|
|
width,
|
|
height,
|
|
});
|
|
}
|
|
else {
|
|
console.warn(`Image with src '${JSON.stringify(node.props.src || node.props.source)}' skipped due to invalid dimensions`);
|
|
}
|
|
}
|
|
};
|
|
const renderImage = (ctx, node, options) => {
|
|
ctx.save();
|
|
clipNode(ctx, node);
|
|
drawImage(ctx, node, options);
|
|
ctx.restore();
|
|
};
|
|
|
|
const CONTENT_COLOR = '#a1c6e7';
|
|
const PADDING_COLOR = '#c4deb9';
|
|
const MARGIN_COLOR = '#f8cca1';
|
|
// TODO: Draw debug boxes using clipping to enhance quality
|
|
const debugContent = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { left, top, width, height, paddingLeft = 0, paddingTop = 0, paddingRight = 0, paddingBottom = 0, borderLeftWidth = 0, borderTopWidth = 0, borderRightWidth = 0, borderBottomWidth = 0, } = node.box;
|
|
ctx
|
|
.fillColor(CONTENT_COLOR)
|
|
.opacity(0.5)
|
|
.rect(left + paddingLeft + borderLeftWidth, top + paddingTop + borderTopWidth, width - paddingLeft - paddingRight - borderRightWidth - borderLeftWidth, height - paddingTop - paddingBottom - borderTopWidth - borderBottomWidth)
|
|
.fill();
|
|
};
|
|
const debugPadding = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { left, top, width, height, paddingLeft = 0, paddingTop = 0, paddingRight = 0, paddingBottom = 0, borderLeftWidth = 0, borderTopWidth = 0, borderRightWidth = 0, borderBottomWidth = 0, } = node.box;
|
|
ctx.fillColor(PADDING_COLOR).opacity(0.5);
|
|
// Padding top
|
|
ctx
|
|
.rect(left + paddingLeft + borderLeftWidth, top + borderTopWidth, width - paddingRight - paddingLeft - borderLeftWidth - borderRightWidth, paddingTop)
|
|
.fill();
|
|
// Padding left
|
|
ctx
|
|
.rect(left + borderLeftWidth, top + borderTopWidth, paddingLeft, height - borderTopWidth - borderBottomWidth)
|
|
.fill();
|
|
// Padding right
|
|
ctx
|
|
.rect(left + width - paddingRight - borderRightWidth, top + borderTopWidth, paddingRight, height - borderTopWidth - borderBottomWidth)
|
|
.fill();
|
|
// Padding bottom
|
|
ctx
|
|
.rect(left + paddingLeft + borderLeftWidth, top + height - paddingBottom - borderBottomWidth, width - paddingRight - paddingLeft - borderLeftWidth - borderRightWidth, paddingBottom)
|
|
.fill();
|
|
};
|
|
const debugMargin = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { left, top, width, height } = node.box;
|
|
const { marginLeft = 0, marginTop = 0, marginRight = 0, marginBottom = 0, } = node.box;
|
|
ctx.fillColor(MARGIN_COLOR).opacity(0.5);
|
|
// Margin top
|
|
ctx.rect(left, top - marginTop, width, marginTop).fill();
|
|
// Margin left
|
|
ctx
|
|
.rect(left - marginLeft, top - marginTop, marginLeft, height + marginTop + marginBottom)
|
|
.fill();
|
|
// Margin right
|
|
ctx
|
|
.rect(left + width, top - marginTop, marginRight, height + marginTop + marginBottom)
|
|
.fill();
|
|
// Margin bottom
|
|
ctx.rect(left, top + height, width, marginBottom).fill();
|
|
};
|
|
const debugText = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { left, top, width, height } = node.box;
|
|
const { marginLeft = 0, marginTop = 0, marginRight = 0, marginBottom = 0, } = node.box;
|
|
const roundedWidth = Math.round(width + marginLeft + marginRight);
|
|
const roundedHeight = Math.round(height + marginTop + marginBottom);
|
|
ctx
|
|
.fontSize(6)
|
|
.opacity(1)
|
|
.fillColor('black')
|
|
.text(`${roundedWidth} x ${roundedHeight}`, left - marginLeft, Math.max(top - marginTop - 4, 1), { width: Infinity });
|
|
};
|
|
const debugOrigin = (ctx, node) => {
|
|
if (node.origin) {
|
|
ctx
|
|
.circle(node.origin.left, node.origin.top, 3)
|
|
.fill('red')
|
|
.circle(node.origin.left, node.origin.top, 5)
|
|
.stroke('red');
|
|
}
|
|
};
|
|
const renderDebug = (ctx, node) => {
|
|
if (!node.props)
|
|
return;
|
|
if (!('debug' in node.props) || !node.props.debug)
|
|
return;
|
|
ctx.save();
|
|
debugContent(ctx, node);
|
|
debugPadding(ctx, node);
|
|
debugMargin(ctx, node);
|
|
debugText(ctx, node);
|
|
debugOrigin(ctx, node);
|
|
ctx.restore();
|
|
};
|
|
|
|
const availableMethods = [
|
|
'dash',
|
|
'clip',
|
|
'save',
|
|
'path',
|
|
'fill',
|
|
'font',
|
|
'text',
|
|
'rect',
|
|
'scale',
|
|
'moveTo',
|
|
'lineTo',
|
|
'stroke',
|
|
'rotate',
|
|
'circle',
|
|
'lineCap',
|
|
'opacity',
|
|
'ellipse',
|
|
'polygon',
|
|
'restore',
|
|
'lineJoin',
|
|
'fontSize',
|
|
'fillColor',
|
|
'lineWidth',
|
|
'translate',
|
|
'miterLimit',
|
|
'strokeColor',
|
|
'fillOpacity',
|
|
'roundedRect',
|
|
'fillAndStroke',
|
|
'strokeOpacity',
|
|
'bezierCurveTo',
|
|
'quadraticCurveTo',
|
|
'linearGradient',
|
|
'radialGradient',
|
|
];
|
|
const painter = (ctx) => {
|
|
const p = availableMethods.reduce((acc, prop) => ({
|
|
...acc,
|
|
[prop]: (...args) => {
|
|
// @ts-expect-error ctx[prop] is a function
|
|
ctx[prop](...args);
|
|
return p;
|
|
},
|
|
}), {});
|
|
return p;
|
|
};
|
|
const renderCanvas = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left, width, height } = node.box;
|
|
const paddingTop = node.box.paddingTop || 0;
|
|
const paddingLeft = node.box.paddingLeft || 0;
|
|
const paddingRight = node.box.paddingRight || 0;
|
|
const paddingBottom = node.box.paddingBottom || 0;
|
|
const availableWidth = width - paddingLeft - paddingRight;
|
|
const availableHeight = height - paddingTop - paddingBottom;
|
|
if (!availableWidth || !availableHeight) {
|
|
console.warn('Canvas element has null width or height. Please provide valid values via the `style` prop in order to correctly render it.');
|
|
}
|
|
ctx.save().translate(left + paddingLeft, top + paddingTop);
|
|
if (node.props.paint) {
|
|
node.props.paint(painter(ctx), availableWidth, availableHeight);
|
|
}
|
|
ctx.restore();
|
|
};
|
|
|
|
// Ref: https://www.w3.org/TR/css-backgrounds-3/#borders
|
|
// This constant is used to approximate a symmetrical arc using a cubic Bezier curve.
|
|
const KAPPA = 4.0 * ((Math.sqrt(2) - 1.0) / 3.0);
|
|
const clipBorderTop = (ctx, layout, style, rtr, rtl) => {
|
|
const { top, left, width, height } = layout;
|
|
const { borderTopWidth, borderRightWidth, borderLeftWidth } = style;
|
|
// Clip outer top border edge
|
|
ctx.moveTo(left + rtl, top);
|
|
ctx.lineTo(left + width - rtr, top);
|
|
// Ellipse coefficients outer top right cap
|
|
const c0 = rtr * (1.0 - KAPPA);
|
|
// Clip outer top right cap
|
|
ctx.bezierCurveTo(left + width - c0, top, left + width, top + c0, left + width, top + rtr);
|
|
// Move down in case the margin exceedes the radius
|
|
const topRightYCoord = top + Math.max(borderTopWidth, rtr);
|
|
ctx.lineTo(left + width, topRightYCoord);
|
|
// Clip inner top right cap
|
|
ctx.lineTo(left + width - borderRightWidth, topRightYCoord);
|
|
// Ellipse coefficients inner top right cap
|
|
const innerTopRightRadiusX = Math.max(rtr - borderRightWidth, 0);
|
|
const innerTopRightRadiusY = Math.max(rtr - borderTopWidth, 0);
|
|
const c1 = innerTopRightRadiusX * (1.0 - KAPPA);
|
|
const c2 = innerTopRightRadiusY * (1.0 - KAPPA);
|
|
// Clip inner top right cap
|
|
ctx.bezierCurveTo(left + width - borderRightWidth, top + borderTopWidth + c2, left + width - borderRightWidth - c1, top + borderTopWidth, left + width - borderRightWidth - innerTopRightRadiusX, top + borderTopWidth);
|
|
// Clip inner top border edge
|
|
ctx.lineTo(left + Math.max(rtl, borderLeftWidth), top + borderTopWidth);
|
|
// Ellipse coefficients inner top left cap
|
|
const innerTopLeftRadiusX = Math.max(rtl - borderLeftWidth, 0);
|
|
const innerTopLeftRadiusY = Math.max(rtl - borderTopWidth, 0);
|
|
const c3 = innerTopLeftRadiusX * (1.0 - KAPPA);
|
|
const c4 = innerTopLeftRadiusY * (1.0 - KAPPA);
|
|
const topLeftYCoord = top + Math.max(borderTopWidth, rtl);
|
|
// Clip inner top left cap
|
|
ctx.bezierCurveTo(left + borderLeftWidth + c3, top + borderTopWidth, left + borderLeftWidth, top + borderTopWidth + c4, left + borderLeftWidth, topLeftYCoord);
|
|
ctx.lineTo(left, topLeftYCoord);
|
|
// Move down in case the margin exceedes the radius
|
|
ctx.lineTo(left, top + rtl);
|
|
// Ellipse coefficients outer top left cap
|
|
const c5 = rtl * (1.0 - KAPPA);
|
|
// Clip outer top left cap
|
|
ctx.bezierCurveTo(left, top + c5, left + c5, top, left + rtl, top);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
// Clip border top cap joins
|
|
if (borderRightWidth) {
|
|
const trSlope = -borderTopWidth / borderRightWidth;
|
|
ctx.moveTo(left + width / 2, trSlope * (-width / 2) + top);
|
|
ctx.lineTo(left + width, top);
|
|
ctx.lineTo(left, top);
|
|
ctx.lineTo(left, top + height);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
if (borderLeftWidth) {
|
|
const trSlope = -borderTopWidth / borderLeftWidth;
|
|
ctx.moveTo(left + width / 2, trSlope * (-width / 2) + top);
|
|
ctx.lineTo(left, top);
|
|
ctx.lineTo(left + width, top);
|
|
ctx.lineTo(left + width, top + height);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
};
|
|
const fillBorderTop = (ctx, layout, style, rtr, rtl) => {
|
|
const { top, left, width } = layout;
|
|
const { borderTopColor, borderTopWidth, borderTopStyle, borderRightWidth, borderLeftWidth, } = style;
|
|
const c0 = rtl * (1.0 - KAPPA);
|
|
const c1 = rtr * (1.0 - KAPPA);
|
|
ctx.moveTo(left, top + Math.max(rtl, borderTopWidth));
|
|
ctx.bezierCurveTo(left, top + c0, left + c0, top, left + rtl, top);
|
|
ctx.lineTo(left + width - rtr, top);
|
|
ctx.bezierCurveTo(left + width - c1, top, left + width, top + c1, left + width, top + rtr);
|
|
ctx.strokeColor(borderTopColor);
|
|
ctx.lineWidth(Math.max(borderRightWidth, borderTopWidth, borderLeftWidth) * 2);
|
|
if (borderTopStyle === 'dashed') {
|
|
ctx.dash(borderTopWidth * 2, { space: borderTopWidth * 1.2 });
|
|
}
|
|
else if (borderTopStyle === 'dotted') {
|
|
ctx.dash(borderTopWidth, { space: borderTopWidth * 1.2 });
|
|
}
|
|
ctx.stroke();
|
|
ctx.undash();
|
|
};
|
|
const clipBorderRight = (ctx, layout, style, rtr, rbr) => {
|
|
const { top, left, width, height } = layout;
|
|
const { borderTopWidth, borderRightWidth, borderBottomWidth } = style;
|
|
// Clip outer right border edge
|
|
ctx.moveTo(left + width, top + rtr);
|
|
ctx.lineTo(left + width, top + height - rbr);
|
|
// Ellipse coefficients outer bottom right cap
|
|
const c0 = rbr * (1.0 - KAPPA);
|
|
// Clip outer top right cap
|
|
ctx.bezierCurveTo(left + width, top + height - c0, left + width - c0, top + height, left + width - rbr, top + height);
|
|
// Move left in case the margin exceedes the radius
|
|
const topBottomXCoord = left + width - Math.max(borderRightWidth, rbr);
|
|
ctx.lineTo(topBottomXCoord, top + height);
|
|
// Clip inner bottom right cap
|
|
ctx.lineTo(topBottomXCoord, top + height - borderBottomWidth);
|
|
// Ellipse coefficients inner bottom right cap
|
|
const innerBottomRightRadiusX = Math.max(rbr - borderRightWidth, 0);
|
|
const innerBottomRightRadiusY = Math.max(rbr - borderBottomWidth, 0);
|
|
const c1 = innerBottomRightRadiusX * (1.0 - KAPPA);
|
|
const c2 = innerBottomRightRadiusY * (1.0 - KAPPA);
|
|
// Clip inner top right cap
|
|
ctx.bezierCurveTo(left + width - borderRightWidth - c1, top + height - borderBottomWidth, left + width - borderRightWidth, top + height - borderBottomWidth - c2, left + width - borderRightWidth, top + height - Math.max(rbr, borderBottomWidth));
|
|
// Clip inner right border edge
|
|
ctx.lineTo(left + width - borderRightWidth, top + Math.max(rtr, borderTopWidth));
|
|
// Ellipse coefficients inner top right cap
|
|
const innerTopRightRadiusX = Math.max(rtr - borderRightWidth, 0);
|
|
const innerTopRightRadiusY = Math.max(rtr - borderTopWidth, 0);
|
|
const c3 = innerTopRightRadiusX * (1.0 - KAPPA);
|
|
const c4 = innerTopRightRadiusY * (1.0 - KAPPA);
|
|
const topRightXCoord = left + width - Math.max(rtr, borderRightWidth);
|
|
// Clip inner top left cap
|
|
ctx.bezierCurveTo(left + width - borderRightWidth, top + borderTopWidth + c4, left + width - borderRightWidth - c3, top + borderTopWidth, topRightXCoord, top + borderTopWidth);
|
|
ctx.lineTo(topRightXCoord, top);
|
|
// Move right in case the margin exceedes the radius
|
|
ctx.lineTo(left + width - rtr, top);
|
|
// Ellipse coefficients outer top right cap
|
|
const c5 = rtr * (1.0 - KAPPA);
|
|
// Clip outer top right cap
|
|
ctx.bezierCurveTo(left + width - c5, top, left + width, top + c5, left + width, top + rtr);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
// Clip border right cap joins
|
|
if (borderTopWidth) {
|
|
const trSlope = -borderTopWidth / borderRightWidth;
|
|
ctx.moveTo(left + width / 2, trSlope * (-width / 2) + top);
|
|
ctx.lineTo(left + width, top);
|
|
ctx.lineTo(left + width, top + height);
|
|
ctx.lineTo(left, top + height);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
if (borderBottomWidth) {
|
|
const brSlope = borderBottomWidth / borderRightWidth;
|
|
ctx.moveTo(left + width / 2, brSlope * (-width / 2) + top + height);
|
|
ctx.lineTo(left + width, top + height);
|
|
ctx.lineTo(left + width, top);
|
|
ctx.lineTo(left, top);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
};
|
|
const fillBorderRight = (ctx, layout, style, rtr, rbr) => {
|
|
const { top, left, width, height } = layout;
|
|
const { borderRightColor, borderRightStyle, borderRightWidth, borderTopWidth, borderBottomWidth, } = style;
|
|
const c0 = rbr * (1.0 - KAPPA);
|
|
const c1 = rtr * (1.0 - KAPPA);
|
|
ctx.moveTo(left + width - rtr, top);
|
|
ctx.bezierCurveTo(left + width - c1, top, left + width, top + c1, left + width, top + rtr);
|
|
ctx.lineTo(left + width, top + height - rbr);
|
|
ctx.bezierCurveTo(left + width, top + height - c0, left + width - c0, top + height, left + width - rbr, top + height);
|
|
ctx.strokeColor(borderRightColor);
|
|
ctx.lineWidth(Math.max(borderRightWidth, borderTopWidth, borderBottomWidth) * 2);
|
|
if (borderRightStyle === 'dashed') {
|
|
ctx.dash(borderRightWidth * 2, { space: borderRightWidth * 1.2 });
|
|
}
|
|
else if (borderRightStyle === 'dotted') {
|
|
ctx.dash(borderRightWidth, { space: borderRightWidth * 1.2 });
|
|
}
|
|
ctx.stroke();
|
|
ctx.undash();
|
|
};
|
|
const clipBorderBottom = (ctx, layout, style, rbl, rbr) => {
|
|
const { top, left, width, height } = layout;
|
|
const { borderBottomWidth, borderRightWidth, borderLeftWidth } = style;
|
|
// Clip outer top border edge
|
|
ctx.moveTo(left + width - rbr, top + height);
|
|
ctx.lineTo(left + rbl, top + height);
|
|
// Ellipse coefficients outer top right cap
|
|
const c0 = rbl * (1.0 - KAPPA);
|
|
// Clip outer top right cap
|
|
ctx.bezierCurveTo(left + c0, top + height, left, top + height - c0, left, top + height - rbl);
|
|
// Move up in case the margin exceedes the radius
|
|
const bottomLeftYCoord = top + height - Math.max(borderBottomWidth, rbl);
|
|
ctx.lineTo(left, bottomLeftYCoord);
|
|
// Clip inner bottom left cap
|
|
ctx.lineTo(left + borderLeftWidth, bottomLeftYCoord);
|
|
// Ellipse coefficients inner top right cap
|
|
const innerBottomLeftRadiusX = Math.max(rbl - borderLeftWidth, 0);
|
|
const innerBottomLeftRadiusY = Math.max(rbl - borderBottomWidth, 0);
|
|
const c1 = innerBottomLeftRadiusX * (1.0 - KAPPA);
|
|
const c2 = innerBottomLeftRadiusY * (1.0 - KAPPA);
|
|
// Clip inner bottom left cap
|
|
ctx.bezierCurveTo(left + borderLeftWidth, top + height - borderBottomWidth - c2, left + borderLeftWidth + c1, top + height - borderBottomWidth, left + borderLeftWidth + innerBottomLeftRadiusX, top + height - borderBottomWidth);
|
|
// Clip inner bottom border edge
|
|
ctx.lineTo(left + width - Math.max(rbr, borderRightWidth), top + height - borderBottomWidth);
|
|
// Ellipse coefficients inner top left cap
|
|
const innerBottomRightRadiusX = Math.max(rbr - borderRightWidth, 0);
|
|
const innerBottomRightRadiusY = Math.max(rbr - borderBottomWidth, 0);
|
|
const c3 = innerBottomRightRadiusX * (1.0 - KAPPA);
|
|
const c4 = innerBottomRightRadiusY * (1.0 - KAPPA);
|
|
const bottomRightYCoord = top + height - Math.max(borderBottomWidth, rbr);
|
|
// Clip inner top left cap
|
|
ctx.bezierCurveTo(left + width - borderRightWidth - c3, top + height - borderBottomWidth, left + width - borderRightWidth, top + height - borderBottomWidth - c4, left + width - borderRightWidth, bottomRightYCoord);
|
|
ctx.lineTo(left + width, bottomRightYCoord);
|
|
// Move down in case the margin exceedes the radius
|
|
ctx.lineTo(left + width, top + height - rbr);
|
|
// Ellipse coefficients outer top left cap
|
|
const c5 = rbr * (1.0 - KAPPA);
|
|
// Clip outer top left cap
|
|
ctx.bezierCurveTo(left + width, top + height - c5, left + width - c5, top + height, left + width - rbr, top + height);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
// Clip border bottom cap joins
|
|
if (borderRightWidth) {
|
|
const brSlope = borderBottomWidth / borderRightWidth;
|
|
ctx.moveTo(left + width / 2, brSlope * (-width / 2) + top + height);
|
|
ctx.lineTo(left + width, top + height);
|
|
ctx.lineTo(left, top + height);
|
|
ctx.lineTo(left, top);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
if (borderLeftWidth) {
|
|
const trSlope = -borderBottomWidth / borderLeftWidth;
|
|
ctx.moveTo(left + width / 2, trSlope * (width / 2) + top + height);
|
|
ctx.lineTo(left, top + height);
|
|
ctx.lineTo(left + width, top + height);
|
|
ctx.lineTo(left + width, top);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
};
|
|
const fillBorderBottom = (ctx, layout, style, rbl, rbr) => {
|
|
const { top, left, width, height } = layout;
|
|
const { borderBottomColor, borderBottomStyle, borderBottomWidth, borderRightWidth, borderLeftWidth, } = style;
|
|
const c0 = rbl * (1.0 - KAPPA);
|
|
const c1 = rbr * (1.0 - KAPPA);
|
|
ctx.moveTo(left + width, top + height - rbr);
|
|
ctx.bezierCurveTo(left + width, top + height - c1, left + width - c1, top + height, left + width - rbr, top + height);
|
|
ctx.lineTo(left + rbl, top + height);
|
|
ctx.bezierCurveTo(left + c0, top + height, left, top + height - c0, left, top + height - rbl);
|
|
ctx.strokeColor(borderBottomColor);
|
|
ctx.lineWidth(Math.max(borderBottomWidth, borderRightWidth, borderLeftWidth) * 2);
|
|
if (borderBottomStyle === 'dashed') {
|
|
ctx.dash(borderBottomWidth * 2, { space: borderBottomWidth * 1.2 });
|
|
}
|
|
else if (borderBottomStyle === 'dotted') {
|
|
ctx.dash(borderBottomWidth, { space: borderBottomWidth * 1.2 });
|
|
}
|
|
ctx.stroke();
|
|
ctx.undash();
|
|
};
|
|
const clipBorderLeft = (ctx, layout, style, rbl, rtl) => {
|
|
const { top, left, width, height } = layout;
|
|
const { borderTopWidth, borderLeftWidth, borderBottomWidth } = style;
|
|
// Clip outer left border edge
|
|
ctx.moveTo(left, top + height - rbl);
|
|
ctx.lineTo(left, top + rtl);
|
|
// Ellipse coefficients outer top left cap
|
|
const c0 = rtl * (1.0 - KAPPA);
|
|
// Clip outer top left cap
|
|
ctx.bezierCurveTo(left, top + c0, left + c0, top, left + rtl, top);
|
|
// Move right in case the margin exceedes the radius
|
|
const topLeftCoordX = left + Math.max(borderLeftWidth, rtl);
|
|
ctx.lineTo(topLeftCoordX, top);
|
|
// Clip inner top left cap
|
|
ctx.lineTo(topLeftCoordX, top + borderTopWidth);
|
|
// Ellipse coefficients inner top left cap
|
|
const innerTopLeftRadiusX = Math.max(rtl - borderLeftWidth, 0);
|
|
const innerTopLeftRadiusY = Math.max(rtl - borderTopWidth, 0);
|
|
const c1 = innerTopLeftRadiusX * (1.0 - KAPPA);
|
|
const c2 = innerTopLeftRadiusY * (1.0 - KAPPA);
|
|
// Clip inner top right cap
|
|
ctx.bezierCurveTo(left + borderLeftWidth + c1, top + borderTopWidth, left + borderLeftWidth, top + borderTopWidth + c2, left + borderLeftWidth, top + Math.max(rtl, borderTopWidth));
|
|
// Clip inner left border edge
|
|
ctx.lineTo(left + borderLeftWidth, top + height - Math.max(rbl, borderBottomWidth));
|
|
// Ellipse coefficients inner bottom left cap
|
|
const innerBottomLeftRadiusX = Math.max(rbl - borderLeftWidth, 0);
|
|
const innerBottomLeftRadiusY = Math.max(rbl - borderBottomWidth, 0);
|
|
const c3 = innerBottomLeftRadiusX * (1.0 - KAPPA);
|
|
const c4 = innerBottomLeftRadiusY * (1.0 - KAPPA);
|
|
const bottomLeftXCoord = left + Math.max(rbl, borderLeftWidth);
|
|
// Clip inner top left cap
|
|
ctx.bezierCurveTo(left + borderLeftWidth, top + height - borderBottomWidth - c4, left + borderLeftWidth + c3, top + height - borderBottomWidth, bottomLeftXCoord, top + height - borderBottomWidth);
|
|
ctx.lineTo(bottomLeftXCoord, top + height);
|
|
// Move left in case the margin exceedes the radius
|
|
ctx.lineTo(left + rbl, top + height);
|
|
// Ellipse coefficients outer top right cap
|
|
const c5 = rbl * (1.0 - KAPPA);
|
|
// Clip outer top right cap
|
|
ctx.bezierCurveTo(left + c5, top + height, left, top + height - c5, left, top + height - rbl);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
// Clip border right cap joins
|
|
if (borderBottomWidth) {
|
|
const trSlope = -borderBottomWidth / borderLeftWidth;
|
|
ctx.moveTo(left + width / 2, trSlope * (width / 2) + top + height);
|
|
ctx.lineTo(left, top + height);
|
|
ctx.lineTo(left, top);
|
|
ctx.lineTo(left + width, top);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
if (borderBottomWidth) {
|
|
const trSlope = -borderTopWidth / borderLeftWidth;
|
|
ctx.moveTo(left + width / 2, trSlope * (-width / 2) + top);
|
|
ctx.lineTo(left, top);
|
|
ctx.lineTo(left, top + height);
|
|
ctx.lineTo(left + width, top + height);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
};
|
|
const fillBorderLeft = (ctx, layout, style, rbl, rtl) => {
|
|
const { top, left, height } = layout;
|
|
const { borderLeftColor, borderLeftStyle, borderLeftWidth, borderTopWidth, borderBottomWidth, } = style;
|
|
const c0 = rbl * (1.0 - KAPPA);
|
|
const c1 = rtl * (1.0 - KAPPA);
|
|
ctx.moveTo(left + rbl, top + height);
|
|
ctx.bezierCurveTo(left + c0, top + height, left, top + height - c0, left, top + height - rbl);
|
|
ctx.lineTo(left, top + rtl);
|
|
ctx.bezierCurveTo(left, top + c1, left + c1, top, left + rtl, top);
|
|
ctx.strokeColor(borderLeftColor);
|
|
ctx.lineWidth(Math.max(borderLeftWidth, borderTopWidth, borderBottomWidth) * 2);
|
|
if (borderLeftStyle === 'dashed') {
|
|
ctx.dash(borderLeftWidth * 2, { space: borderLeftWidth * 1.2 });
|
|
}
|
|
else if (borderLeftStyle === 'dotted') {
|
|
ctx.dash(borderLeftWidth, { space: borderLeftWidth * 1.2 });
|
|
}
|
|
ctx.stroke();
|
|
ctx.undash();
|
|
};
|
|
const shouldRenderBorders = (node) => node.box &&
|
|
(node.box.borderTopWidth ||
|
|
node.box.borderRightWidth ||
|
|
node.box.borderBottomWidth ||
|
|
node.box.borderLeftWidth);
|
|
const renderBorders = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
if (!shouldRenderBorders(node))
|
|
return;
|
|
const { width, height, borderTopWidth = 0, borderLeftWidth = 0, borderRightWidth = 0, borderBottomWidth = 0, } = node.box;
|
|
const { opacity = 1, borderTopColor = 'black', borderTopStyle = 'solid', borderLeftColor = 'black', borderLeftStyle = 'solid', borderRightColor = 'black', borderRightStyle = 'solid', borderBottomColor = 'black', borderBottomStyle = 'solid', } = node.style;
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const borderTopLeftRadius = node.style.borderTopLeftRadius || 0;
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const borderTopRightRadius = node.style.borderTopRightRadius || 0;
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const borderBottomLeftRadius = node.style.borderBottomLeftRadius || 0;
|
|
// @ts-expect-error this is always a number due to resolve border radius step
|
|
const borderBottomRightRadius = node.style.borderBottomRightRadius || 0;
|
|
const style = {
|
|
borderTopColor,
|
|
borderTopWidth,
|
|
borderTopStyle,
|
|
borderLeftColor,
|
|
borderLeftWidth,
|
|
borderLeftStyle,
|
|
borderRightColor,
|
|
borderRightWidth,
|
|
borderRightStyle,
|
|
borderBottomColor,
|
|
borderBottomWidth,
|
|
borderBottomStyle};
|
|
const rtr = Math.min(borderTopRightRadius, 0.5 * width, 0.5 * height);
|
|
const rtl = Math.min(borderTopLeftRadius, 0.5 * width, 0.5 * height);
|
|
const rbr = Math.min(borderBottomRightRadius, 0.5 * width, 0.5 * height);
|
|
const rbl = Math.min(borderBottomLeftRadius, 0.5 * width, 0.5 * height);
|
|
ctx.save();
|
|
ctx.strokeOpacity(opacity);
|
|
if (borderTopWidth) {
|
|
ctx.save();
|
|
clipBorderTop(ctx, node.box, style, rtr, rtl);
|
|
fillBorderTop(ctx, node.box, style, rtr, rtl);
|
|
ctx.restore();
|
|
}
|
|
if (borderRightWidth) {
|
|
ctx.save();
|
|
clipBorderRight(ctx, node.box, style, rtr, rbr);
|
|
fillBorderRight(ctx, node.box, style, rtr, rbr);
|
|
ctx.restore();
|
|
}
|
|
if (borderBottomWidth) {
|
|
ctx.save();
|
|
clipBorderBottom(ctx, node.box, style, rbl, rbr);
|
|
fillBorderBottom(ctx, node.box, style, rbl, rbr);
|
|
ctx.restore();
|
|
}
|
|
if (borderLeftWidth) {
|
|
ctx.save();
|
|
clipBorderLeft(ctx, node.box, style, rbl, rtl);
|
|
fillBorderLeft(ctx, node.box, style, rbl, rtl);
|
|
ctx.restore();
|
|
}
|
|
ctx.restore();
|
|
};
|
|
|
|
const drawBackground = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left, width, height } = node.box;
|
|
const color = parseColor(node.style.backgroundColor);
|
|
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();
|
|
};
|
|
const renderBackground = (ctx, node) => {
|
|
const hasBackground = !!node.box && !!node.style?.backgroundColor;
|
|
if (hasBackground) {
|
|
ctx.save();
|
|
clipNode(ctx, node);
|
|
drawBackground(ctx, node);
|
|
ctx.restore();
|
|
}
|
|
};
|
|
|
|
const isString = (value) => typeof value === 'string';
|
|
const isSrcId = (value) => /^#.+/.test(value);
|
|
const renderLink = (ctx, node, src) => {
|
|
if (!src || !node.box)
|
|
return;
|
|
const isId = isSrcId(src);
|
|
const method = isId ? 'goTo' : 'link';
|
|
const value = isId ? src.slice(1) : src;
|
|
const { top, left, width, height } = node.box;
|
|
ctx[method](left, top, width, height, value);
|
|
};
|
|
const setLink = (ctx, node) => {
|
|
const props = node.props || {};
|
|
if ('src' in props && isString(props.src))
|
|
return renderLink(ctx, node, props.src);
|
|
if ('href' in props && isString(props.href))
|
|
return renderLink(ctx, node, props.href);
|
|
};
|
|
|
|
const setDestination = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
if (!node.props)
|
|
return;
|
|
if ('id' in node.props) {
|
|
ctx.addNamedDestination(node.props.id, 'XYZ', null, node.box.top, null);
|
|
}
|
|
};
|
|
|
|
const clean = (options) => {
|
|
const opt = { ...options };
|
|
// We need to ensure the elements are no present if not true
|
|
Object.entries(opt).forEach((pair) => {
|
|
if (!pair[1]) {
|
|
delete opt[pair[0]];
|
|
}
|
|
});
|
|
return opt;
|
|
};
|
|
const parseCommonFormOptions = (node) => {
|
|
// Common Options
|
|
return {
|
|
required: node.props?.required || false,
|
|
noExport: node.props?.noExport || false,
|
|
readOnly: node.props?.readOnly || false,
|
|
value: node.props?.value || undefined,
|
|
defaultValue: node.props?.defaultValue || undefined,
|
|
};
|
|
};
|
|
const parseTextInputOptions = (node, fieldSet) => {
|
|
return clean({
|
|
...parseCommonFormOptions(node),
|
|
parent: fieldSet || undefined,
|
|
align: node.props?.align || 'left',
|
|
multiline: node.props?.multiline || undefined,
|
|
password: node.props?.password || false,
|
|
noSpell: node.props?.noSpell || false,
|
|
format: node.props?.format || undefined,
|
|
fontSize: node.props?.fontSize || undefined,
|
|
MaxLen: node.props?.maxLength || undefined,
|
|
});
|
|
};
|
|
const parseSelectAndListFieldOptions = (node) => {
|
|
return clean({
|
|
...parseCommonFormOptions(node),
|
|
sort: node.props?.sort || false,
|
|
edit: node.props?.edit || false,
|
|
multiSelect: node.props?.multiSelect || false,
|
|
noSpell: node.props?.noSpell || false,
|
|
select: node.props?.select || [''],
|
|
});
|
|
};
|
|
const getAppearance = (ctx, codepoint, width, height) => {
|
|
const appearance = ctx.ref({
|
|
Type: 'XObject',
|
|
Subtype: 'Form',
|
|
BBox: [0, 0, width, height],
|
|
Resources: {
|
|
ProcSet: ['PDF', 'Text', 'ImageB', 'ImageC', 'ImageI'],
|
|
Font: {
|
|
ZaDi: ctx._acroform.fonts.ZaDi,
|
|
},
|
|
},
|
|
});
|
|
appearance.initDeflate();
|
|
appearance.write(`/Tx BMC\nq\n/ZaDi ${height * 0.8} Tf\nBT\n${width * 0.45} ${height / 4} Td (${codepoint}) Tj\nET\nQ\nEMC`);
|
|
appearance.end(null);
|
|
return appearance;
|
|
};
|
|
const parseCheckboxOptions = (ctx, node, fieldSet) => {
|
|
const { width, height } = node.box || {};
|
|
const onOption = node.props?.onState || 'Yes';
|
|
const offOption = node.props?.offState || 'Off';
|
|
const xMark = node.props?.xMark || false;
|
|
if (!Object.prototype.hasOwnProperty.call(ctx._acroform.fonts, 'ZaDi')) {
|
|
const ref = ctx.ref({
|
|
Type: 'Font',
|
|
Subtype: 'Type1',
|
|
BaseFont: 'ZapfDingbats',
|
|
});
|
|
ctx._acroform.fonts.ZaDi = ref;
|
|
ref.end(null);
|
|
}
|
|
const normalAppearance = {
|
|
[onOption]: getAppearance(ctx, xMark ? '8' : '4', width, height),
|
|
[offOption]: getAppearance(ctx, xMark ? ' ' : '8', width, height),
|
|
};
|
|
return clean({
|
|
...parseCommonFormOptions(node),
|
|
backgroundColor: node.props?.backgroundColor || undefined,
|
|
borderColor: node.props?.borderColor || undefined,
|
|
parent: fieldSet || undefined,
|
|
value: `/${node.props?.checked === true ? onOption : offOption}`,
|
|
defaultValue: `/${node.props?.checked === true ? onOption : offOption}`,
|
|
AS: node.props?.checked === true ? onOption : offOption,
|
|
AP: { N: normalAppearance, D: normalAppearance },
|
|
});
|
|
};
|
|
|
|
const renderTextInput = (ctx, node, options) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left, width, height } = node.box;
|
|
// Element's name
|
|
const name = node.props?.name || '';
|
|
const fieldSetOptions = options.fieldSets?.at(0);
|
|
if (!ctx._root.data.AcroForm) {
|
|
ctx.initForm();
|
|
}
|
|
ctx.formText(name, left, top, width, height, parseTextInputOptions(node, fieldSetOptions));
|
|
};
|
|
|
|
const renderSelect = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left, width, height } = node.box;
|
|
// Element's name
|
|
const name = node.props?.name || '';
|
|
if (!ctx._root.data.AcroForm) {
|
|
ctx.initForm();
|
|
}
|
|
ctx.formCombo(name, left, top, width, height, parseSelectAndListFieldOptions(node));
|
|
};
|
|
|
|
const renderFieldSet = (ctx, node, options) => {
|
|
const name = node.props?.name || '';
|
|
if (!ctx._root.data.AcroForm) {
|
|
ctx.initForm();
|
|
}
|
|
const formField = ctx.formField(name);
|
|
const option = options;
|
|
if (!option.fieldSets) {
|
|
option.fieldSets = [formField];
|
|
}
|
|
else {
|
|
option.fieldSets.push(formField);
|
|
}
|
|
};
|
|
const cleanUpFieldSet = (_ctx, _node, options) => {
|
|
options.fieldSets.pop();
|
|
};
|
|
|
|
const renderList = (ctx, node) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left, width, height } = node.box || {};
|
|
// Element's name
|
|
const name = ('name' in node.props ? node.props.name || '' : '');
|
|
if (!ctx._root.data.AcroForm) {
|
|
ctx.initForm();
|
|
}
|
|
ctx.formList(name, left, top, width, height, parseSelectAndListFieldOptions(node));
|
|
};
|
|
|
|
const renderCheckbox = (ctx, node, options) => {
|
|
if (!node.box)
|
|
return;
|
|
const { top, left, width, height } = node.box;
|
|
// Element's name
|
|
const name = node.props?.name || '';
|
|
const fieldSetOptions = options.fieldSets?.at(0);
|
|
if (!ctx._root.data.AcroForm) {
|
|
ctx.initForm();
|
|
}
|
|
ctx.formCheckbox(name, left, top, width, height, parseCheckboxOptions(ctx, node, fieldSetOptions));
|
|
};
|
|
|
|
const isRecursiveNode = (node) => node.type !== P.Text && node.type !== P.Svg;
|
|
const renderChildren = (ctx, node, options) => {
|
|
ctx.save();
|
|
if (node.box) {
|
|
ctx.translate(node.box.left, node.box.top);
|
|
}
|
|
const children = node.children || [];
|
|
const renderChild = (child) => renderNode(ctx, child, options);
|
|
children.forEach(renderChild);
|
|
ctx.restore();
|
|
};
|
|
const renderFns = {
|
|
[P.Text]: renderText,
|
|
[P.Note]: renderNote,
|
|
[P.Image]: renderImage,
|
|
[P.FieldSet]: renderFieldSet,
|
|
[P.TextInput]: renderTextInput,
|
|
[P.Select]: renderSelect,
|
|
[P.Checkbox]: renderCheckbox,
|
|
[P.List]: renderList,
|
|
[P.Canvas]: renderCanvas,
|
|
[P.Svg]: renderSvg,
|
|
[P.Link]: setLink,
|
|
};
|
|
const cleanUpFns = {
|
|
[P.FieldSet]: cleanUpFieldSet,
|
|
};
|
|
const renderNode = (ctx, node, options) => {
|
|
const overflowHidden = node.style?.overflow === 'hidden';
|
|
const shouldRenderChildren = isRecursiveNode(node);
|
|
if (node.type === P.Page)
|
|
renderPage(ctx, node);
|
|
ctx.save();
|
|
if (overflowHidden)
|
|
clipNode(ctx, node);
|
|
applyTransformations(ctx, node);
|
|
renderBackground(ctx, node);
|
|
renderBorders(ctx, node);
|
|
const renderFn = renderFns[node.type];
|
|
if (renderFn)
|
|
renderFn(ctx, node, options);
|
|
if (shouldRenderChildren)
|
|
renderChildren(ctx, node, options);
|
|
const cleanUpFn = cleanUpFns[node.type];
|
|
if (cleanUpFn)
|
|
cleanUpFn(ctx, node, options);
|
|
setDestination(ctx, node);
|
|
renderDebug(ctx, node);
|
|
ctx.restore();
|
|
};
|
|
|
|
const addNodeBookmark = (ctx, node, pageNumber, registry) => {
|
|
if (!node.box)
|
|
return;
|
|
if (!node.props)
|
|
return;
|
|
if ('bookmark' in node.props && node.props.bookmark) {
|
|
const bookmark = node.props.bookmark;
|
|
const { title, parent, expanded, zoom, fit } = bookmark;
|
|
const outline = registry[parent] || ctx.outline;
|
|
const top = bookmark.top || node.box.top;
|
|
const left = bookmark.left || node.box.left;
|
|
const instance = outline.addItem(title, {
|
|
pageNumber,
|
|
expanded,
|
|
top,
|
|
left,
|
|
zoom,
|
|
fit,
|
|
});
|
|
registry[bookmark.ref] = instance;
|
|
}
|
|
if (!node.children)
|
|
return;
|
|
node.children.forEach((child) => addNodeBookmark(ctx, child, pageNumber, registry));
|
|
};
|
|
const addBookmarks = (ctx, root) => {
|
|
const registry = {};
|
|
const pages = root.children || [];
|
|
pages.forEach((page, i) => {
|
|
addNodeBookmark(ctx, page, i, registry);
|
|
});
|
|
};
|
|
|
|
const render = (ctx, doc) => {
|
|
const pages = doc.children || [];
|
|
const options = { imageCache: new Map(), fieldSets: [] };
|
|
pages.forEach((page) => renderNode(ctx, page, options));
|
|
addBookmarks(ctx, doc);
|
|
ctx.end();
|
|
return ctx;
|
|
};
|
|
|
|
export { render as default };
|