import {
parse as parseHtml,
HTMLElement,
Attributes,
} from 'node-html-better-parser';
import { Color, colorString } from './colors';
import { Degrees, degreesToRadians } from './rotations';
import PDFPage from './PDFPage';
import { PDFPageDrawSVGElementOptions } from './PDFPageOptions';
import { LineCapStyle, LineJoinStyle } from './operators';
interface Position {
x: number;
y: number;
}
interface Constraints {
width?: number;
height?: number;
}
type PDFPageDrawSVGElementOptionsRequireds = PDFPageDrawSVGElementOptions &
Position;
interface SVGViewBox {
xMin: number;
yMin: number;
width: number;
height: number;
}
interface SVGSizeConverter {
x: (real: number) => number;
y: (real: number) => number;
}
type SVGStyle = Record;
interface SVGAttributes {
width: number;
height: number;
x: number;
y: number;
fill?: Color;
fillOpacity?: number;
stroke?: Color;
strokeWidth?: number;
strokeOpacity?: number;
strokeLineCap?: LineCapStyle;
strokeLineJoin?: LineJoinStyle;
rotate?: Degrees;
scale?: number;
skewX?: Degrees;
skewY?: Degrees;
viewBox?: SVGViewBox;
converter?: SVGSizeConverter;
cx: number;
cy: number;
r: number;
rx: number;
ry: number;
x1: number;
y1: number;
x2: number;
y2: number;
d: string;
src: string;
fontFamily?: string;
fontSize?: number;
}
export type SVGElement = HTMLElement & {
svgAttributes: SVGAttributes;
};
interface SVGElementToDrawMap {
[cmd: string]: (a: SVGElement) => Promise;
}
const StrokeLineCapMap: Record = {
'butt': LineCapStyle.Butt,
'round': LineCapStyle.Round,
'square': LineCapStyle.Projecting,
};
const StrokeLineJoinMap: Record = {
'bevel': LineJoinStyle.Bevel,
'miter': LineJoinStyle.Miter,
'round': LineJoinStyle.Round,
};
const runnersToPage = (
page: PDFPage,
options: PDFPageDrawSVGElementOptionsRequireds,
): SVGElementToDrawMap => ({
async text(element) {
page.drawText(element.childNodes[0].text, {
x: options.x + element.svgAttributes.x,
y: options.y - element.svgAttributes.y,
font:
options.fonts && element.svgAttributes.fontFamily
? options.fonts[element.svgAttributes.fontFamily]
: undefined,
size: element.svgAttributes.fontSize,
color: element.svgAttributes.fill,
opacity: element.svgAttributes.fillOpacity,
});
},
async line(element) {
page.drawLine({
start: {
x: options.x + element.svgAttributes.x1,
y: options.y - element.svgAttributes.y1,
},
end: {
x: options.x + element.svgAttributes.x2,
y: options.y - element.svgAttributes.y2,
},
thickness: element.svgAttributes.strokeWidth,
color: element.svgAttributes.stroke,
opacity: element.svgAttributes.strokeOpacity,
lineCap: element.svgAttributes.strokeLineCap,
});
},
async path(element) {
page.drawSvgPath(element.svgAttributes.d, {
x: options.x + element.svgAttributes.x,
y: options.y - element.svgAttributes.y,
borderColor: element.svgAttributes.stroke,
borderWidth: element.svgAttributes.strokeWidth,
borderOpacity: element.svgAttributes.strokeOpacity,
borderLineCap: element.svgAttributes.strokeLineCap,
color: element.svgAttributes.fill,
opacity: element.svgAttributes.fillOpacity,
scale: element.svgAttributes.scale,
rotate: element.svgAttributes.rotate,
});
},
async image(element) {
page.drawImage(await page.doc.embedPng(element.svgAttributes.src), {
x: options.x + element.svgAttributes.x,
y: options.y - element.svgAttributes.y,
width: element.svgAttributes.width,
height: element.svgAttributes.height,
opacity: element.svgAttributes.fillOpacity,
xSkew: element.svgAttributes.skewX,
ySkew: element.svgAttributes.skewY,
rotate: element.svgAttributes.rotate,
});
},
async rect(element) {
if (!element.svgAttributes.fill && !element.svgAttributes.stroke) return;
page.drawRectangle({
x: options.x + element.svgAttributes.x,
y: options.y - element.svgAttributes.y,
width: element.svgAttributes.width,
height: element.svgAttributes.height,
borderColor: element.svgAttributes.stroke,
borderWidth: element.svgAttributes.strokeWidth,
borderOpacity: element.svgAttributes.strokeOpacity,
borderLineCap: element.svgAttributes.strokeLineCap,
color: element.svgAttributes.fill,
opacity: element.svgAttributes.fillOpacity,
xSkew: element.svgAttributes.skewX,
ySkew: element.svgAttributes.skewY,
rotate: element.svgAttributes.rotate,
});
},
async ellipse(element) {
page.drawEllipse({
x: options.x + element.svgAttributes.x + element.svgAttributes.cx,
y: options.y - element.svgAttributes.y - element.svgAttributes.cy,
xScale: element.svgAttributes.rx,
yScale: element.svgAttributes.ry,
borderColor: element.svgAttributes.stroke,
borderWidth: element.svgAttributes.strokeWidth,
borderOpacity: element.svgAttributes.strokeOpacity,
borderLineCap: element.svgAttributes.strokeLineCap,
color: element.svgAttributes.fill,
opacity: element.svgAttributes.fillOpacity,
rotate: element.svgAttributes.rotate,
});
},
async circle(element) {
page.drawCircle({
x: options.x + element.svgAttributes.x + element.svgAttributes.cx,
y: options.y - element.svgAttributes.y - element.svgAttributes.cy,
size: element.svgAttributes.r,
borderColor: element.svgAttributes.stroke,
borderWidth: element.svgAttributes.strokeWidth,
borderOpacity: element.svgAttributes.strokeOpacity,
borderLineCap: element.svgAttributes.strokeLineCap,
color: element.svgAttributes.fill,
opacity: element.svgAttributes.fillOpacity,
});
},
});
const transform = (
{ x, y }: Position,
name: string,
args: number[],
): Position => {
let angle;
let tempResult;
switch (name) {
case 'scale':
x = x * args[0];
y = y * (args.length > 1 ? args[1] : args[0]);
break;
case 'translate':
x = x + args[0];
y = y + args[1] || 0;
break;
case 'rotate':
if (args.length === 1) {
angle = degreesToRadians(args[0]);
x = Math.cos(angle) * x - Math.sin(angle) * y;
y = Math.sin(angle) * x + Math.cos(angle) * y;
} else {
tempResult = transform({ x, y }, 'translate', [args[1], args[2]]);
tempResult = transform(tempResult, 'rotate', [args[0]]);
tempResult = transform(tempResult, 'translate', [
-args[1],
-(args[2] || 0),
]);
x = tempResult.x;
y = tempResult.y;
}
break;
case 'skewX':
angle = degreesToRadians(args[0]);
x = x + Math.tan(angle) * y;
break;
case 'skewY':
angle = degreesToRadians(args[0]);
y = Math.tan(angle) * x + y;
break;
}
return { x, y };
};
const styleOrAttribute = (
attributes: Attributes,
style: SVGStyle,
attribute: string,
def?: string,
): string => {
const value = style[attribute] || attributes[attribute];
if (!value && typeof def !== 'undefined') return def;
return value;
};
const parseStyles = (style: string): SVGStyle => {
const cssRegex = /([^:\s]+)*\s*:\s*([^;]+)/g;
const css: SVGStyle = {};
let match = cssRegex.exec(style);
while (match != null) {
css[match[1]] = match[2];
match = cssRegex.exec(style);
}
return css;
};
const parseColor = (color: string): Color | undefined => {
if (!color || color.length === 0) return undefined;
if (['none', 'transparent'].includes(color)) return undefined;
return colorString(color);
}
const parseAttributes = (
element: HTMLElement,
parentElement?: SVGElement,
constraints?: Constraints,
): SVGAttributes => {
const attributes = element.attributes;
const style = parseStyles(attributes.style);
const widthRaw = styleOrAttribute(attributes, style, 'width', '');
const heightRaw = styleOrAttribute(attributes, style, 'height', '');
const fillRaw = parseColor(styleOrAttribute(attributes, style, 'fill'));
const fillOpacityRaw = styleOrAttribute(attributes, style, 'fill-opacity');
const strokeRaw = parseColor(styleOrAttribute(attributes, style, 'stroke'));
const strokeOpacityRaw = styleOrAttribute(
attributes,
style,
'stroke-opacity',
);
const strokeLineCapRaw = styleOrAttribute(attributes, style, 'stroke-linecap');
const strokeLineJoinRaw = styleOrAttribute(attributes, style, 'stroke-linejoin');
const strokeWidthRaw = styleOrAttribute(attributes, style, 'stroke-width');
const fontFamilyRaw = styleOrAttribute(attributes, style, 'font-family');
const fontSizeRaw = styleOrAttribute(attributes, style, 'font-size');
const box =
(parentElement && parentElement.svgAttributes) || ({} as SVGAttributes);
let width: number | undefined;
if (widthRaw && parseFloat(widthRaw)) {
width = parseFloat(widthRaw);
if (widthRaw.includes('%')) {
width = (box.width || 0) * (width / 100.0);
}
} else if (box.width) {
width = box.width;
}
let height: number | undefined;
if (heightRaw && parseFloat(heightRaw)) {
height = parseFloat(heightRaw);
if (heightRaw.includes('%')) {
height = (box.height || 0) * (height / 100.0);
}
} else if (box.height) {
height = box.height;
}
let x = typeof attributes.x !== 'undefined' ? parseFloat(attributes.x) : 0;
let y = typeof attributes.y !== 'undefined' ? parseFloat(attributes.y) : 0;
let x1 = typeof attributes.x !== 'undefined' ? parseFloat(attributes.x1) : 0;
let y1 = typeof attributes.y !== 'undefined' ? parseFloat(attributes.y1) : 0;
let x2 = typeof attributes.x !== 'undefined' ? parseFloat(attributes.x2) : 0;
let y2 = typeof attributes.y !== 'undefined' ? parseFloat(attributes.y2) : 0;
let cx = typeof attributes.cx !== 'undefined' ? parseFloat(attributes.cx) : 0;
let cy = typeof attributes.cy !== 'undefined' ? parseFloat(attributes.cy) : 0;
let r = typeof attributes.r !== 'undefined' ? parseFloat(attributes.r) : 0;
let rx = typeof attributes.rx !== 'undefined' ? parseFloat(attributes.rx) : 0;
let ry = typeof attributes.ry !== 'undefined' ? parseFloat(attributes.ry) : 0;
const fill = fillRaw || box.fill;
const fillOpacity =
typeof fillOpacityRaw !== 'undefined'
? parseFloat(fillOpacityRaw)
: box.fillOpacity;
const stroke = strokeRaw || box.stroke;
const strokeOpacity =
typeof strokeOpacityRaw !== 'undefined'
? parseFloat(strokeOpacityRaw)
: box.strokeOpacity;
const strokeLineCap = StrokeLineCapMap[strokeLineCapRaw] || box.strokeLineCap;
const strokeLineJoin = StrokeLineJoinMap[strokeLineJoinRaw] || box.strokeLineJoin;
const strokeWidth =
typeof strokeWidthRaw !== 'undefined'
? parseFloat(strokeWidthRaw)
: box.strokeWidth;
const fontFamily = fontFamilyRaw || box.fontFamily;
const fontSize =
typeof fontSizeRaw !== 'undefined' ? parseFloat(fontSizeRaw) : box.fontSize;
if (attributes.transform) {
const regexTransform = /(\w+)\((.+?)\)/g;
let parsed = regexTransform.exec(attributes.transform);
while (parsed !== null) {
const [, name, rawArgs] = parsed;
const args = (rawArgs || '')
.split(/\s*,\s*|\s+/)
.filter((value) => value.length > 0)
.map((value) => parseFloat(value));
const result = transform({ x, y }, name, args);
x = result.x;
y = result.y;
parsed = regexTransform.exec(attributes.transform);
}
}
if (box.converter) {
x = box.converter.x(x);
y = box.converter.y(y);
x1 = box.converter.x(x1);
y1 = box.converter.y(y1);
x2 = box.converter.x(x2);
y2 = box.converter.y(y2);
cx = box.converter.x(cx);
cy = box.converter.y(cy);
r = box.converter.x(r);
rx = box.converter.x(rx);
ry = box.converter.y(ry);
}
if (constraints?.width) {
const constraintsConverterX = (xReal: number) =>
(xReal * (width || 0)) / (constraints.width || 1);
x = constraintsConverterX(x);
x1 = constraintsConverterX(x1);
x2 = constraintsConverterX(x2);
cx = constraintsConverterX(cx);
r = constraintsConverterX(r);
rx = constraintsConverterX(rx);
}
if (constraints?.height) {
const constraintsConverterY = (yReal: number) =>
(yReal * (height || 0)) / (constraints.height || 1);
y = constraintsConverterY(y);
y1 = constraintsConverterY(y1);
y2 = constraintsConverterY(y2);
cy = constraintsConverterY(cy);
ry = constraintsConverterY(ry);
}
let viewBox;
let converter;
if (['g', 'svg'].includes(element.tagName)) {
viewBox = (attributes.viewBox || '')
.split(/\s*,\s*|\s+/)
.filter((value) => value.length > 0)
.map((value) => parseFloat(value))
.reduce(
(pos, value, i) => {
if (i === 0) pos.xMin = value;
if (i === 1) pos.yMin = value;
if (i === 2) pos.width = value;
if (i === 3) pos.height = value;
return pos;
},
{
xMin: 0,
yMin: 0,
width: width || 0,
height: height || 0,
},
);
if (!width) {
width = viewBox.width;
}
if (!height) {
height = viewBox.height;
}
const boxXmin = viewBox.xMin;
const boxYmin = viewBox.yMin;
const boxWidth = viewBox.width;
const boxHeight = viewBox.height;
converter = {
x: (xReal: number) =>
x + ((xReal - boxXmin) * (width || 0)) / (boxWidth - boxXmin),
y: (yReal: number) =>
y + ((yReal - boxYmin) * (height || 0)) / (boxHeight - boxYmin),
};
}
if (element.tagName === 'text' && fontSize) {
y += fontSize;
}
return {
width: width || 0,
height: width || 0,
x,
y,
fill,
fillOpacity,
stroke,
strokeWidth,
strokeOpacity,
strokeLineCap,
strokeLineJoin,
cx,
cy,
r,
rx,
ry,
x1,
y1,
x2,
y2,
d: attributes.d,
src: attributes.src,
fontFamily,
fontSize,
viewBox,
converter,
};
};
const parseSvgElement = (
element: string | HTMLElement,
parentElement?: SVGElement,
constraints?: Constraints,
): SVGElement => {
const htmlElement =
typeof element === 'string'
? (parseHtml(element).firstChild as HTMLElement)
: element;
return Object.assign({}, htmlElement, {
svgAttributes: parseAttributes(htmlElement, parentElement, constraints),
}) as SVGElement;
};
const parse = (
svg: string | SVGElement,
constraints?: Constraints,
): SVGElement[] => {
const ret: SVGElement[] = [];
const parentElement =
typeof svg === 'string'
? parseSvgElement(svg, undefined, constraints)
: svg;
for (const childNode of parentElement.childNodes) {
if (childNode.nodeType !== 1) continue;
const element = parseSvgElement(childNode as HTMLElement, parentElement);
if (['g', 'svg'].includes(element.tagName)) {
ret.push(...parse(element));
} else {
ret.push(element);
}
}
return ret;
};
export const drawSvg = async (
page: PDFPage,
svg: string,
options: PDFPageDrawSVGElementOptionsRequireds,
) => {
const elements = parse(svg, options);
const runners = runnersToPage(page, options);
for (let i = 0; i < elements.length; i++) {
const c = elements[i];
if (typeof runners[c.tagName] === 'function') {
await runners[c.tagName](c);
}
}
};