fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
231 lines (217 loc) • 7.37 kB
text/typescript
import { Gradient } from '../gradient/Gradient';
import { Group } from '../shapes/Group';
import { FabricImage } from '../shapes/Image';
import { classRegistry } from '../ClassRegistry';
import {
invertTransform,
multiplyTransformMatrices,
qrDecompose,
} from '../util/misc/matrix';
import { removeTransformMatrixForSvgParsing } from '../util/transform_matrix_removal';
import type { FabricObject } from '../shapes/Object/FabricObject';
import { Point } from '../Point';
import { CENTER, FILL, STROKE } from '../constants';
import { getGradientDefs } from './getGradientDefs';
import { getCSSRules } from './getCSSRules';
import type { LoadImageOptions } from '../util';
import type { CSSRules, TSvgReviverCallback } from './typedefs';
import type { ParsedViewboxTransform } from './applyViewboxTransform';
import type { SVGOptions } from '../gradient';
import { getTagName } from './getTagName';
import { parseTransformAttribute } from './parseTransformAttribute';
const findTag = (el: Element) =>
classRegistry.getSVGClass(getTagName(el).toLowerCase());
type StorageType = {
fill: SVGGradientElement;
stroke: SVGGradientElement;
clipPath: Element[];
};
type NotParsedFabricObject = FabricObject & {
fill: string;
stroke: string;
clipPath?: string;
clipRule?: CanvasFillRule;
};
export class ElementsParser {
declare elements: Element[];
declare options: LoadImageOptions & ParsedViewboxTransform;
declare reviver?: TSvgReviverCallback;
declare regexUrl: RegExp;
declare doc: Document;
declare clipPaths: Record<string, Element[]>;
declare gradientDefs: Record<string, SVGGradientElement>;
declare cssRules: CSSRules;
constructor(
elements: Element[],
options: LoadImageOptions & ParsedViewboxTransform,
reviver: TSvgReviverCallback | undefined,
doc: Document,
clipPaths: Record<string, Element[]>,
) {
this.elements = elements;
this.options = options;
this.reviver = reviver;
this.regexUrl = /^url\(['"]?#([^'"]+)['"]?\)/g;
this.doc = doc;
this.clipPaths = clipPaths;
this.gradientDefs = getGradientDefs(doc);
this.cssRules = getCSSRules(doc);
}
parse(): Promise<Array<FabricObject | null>> {
return Promise.all(
this.elements.map((element) => this.createObject(element)),
);
}
async createObject(el: Element): Promise<FabricObject | null> {
const klass = findTag(el);
if (klass) {
const obj: NotParsedFabricObject = await klass.fromElement(
el,
this.options,
this.cssRules,
);
this.resolveGradient(obj, el, FILL);
this.resolveGradient(obj, el, STROKE);
if (obj instanceof FabricImage && obj._originalElement) {
removeTransformMatrixForSvgParsing(
obj,
obj.parsePreserveAspectRatioAttribute(),
);
} else {
removeTransformMatrixForSvgParsing(obj);
}
await this.resolveClipPath(obj, el);
this.reviver && this.reviver(el, obj);
return obj;
}
return null;
}
extractPropertyDefinition(
obj: NotParsedFabricObject,
property: 'fill' | 'stroke' | 'clipPath',
storage: Record<string, StorageType[typeof property]>,
): StorageType[typeof property] | undefined {
const value = obj[property]!,
regex = this.regexUrl;
if (!regex.test(value)) {
return undefined;
}
// verify: can we remove the 'g' flag? and remove lastIndex changes?
regex.lastIndex = 0;
// we passed the regex test, so we know is not null;
const id = regex.exec(value)![1];
regex.lastIndex = 0;
// @todo fix this
return storage[id];
}
resolveGradient(
obj: NotParsedFabricObject,
el: Element,
property: 'fill' | 'stroke',
) {
const gradientDef = this.extractPropertyDefinition(
obj,
property,
this.gradientDefs,
) as SVGGradientElement;
if (gradientDef) {
const opacityAttr = el.getAttribute(property + '-opacity');
const gradient = Gradient.fromElement(gradientDef, obj, {
...this.options,
opacity: opacityAttr,
} as SVGOptions);
obj.set(property, gradient);
}
}
// TODO: resolveClipPath could be run once per clippath with minor work per object.
// is a refactor that i m not sure is worth on this code
async resolveClipPath(
obj: NotParsedFabricObject,
usingElement: Element,
exactOwner?: Element,
) {
const clipPathElements = this.extractPropertyDefinition(
obj,
'clipPath',
this.clipPaths,
) as Element[];
if (clipPathElements) {
const objTransformInv = invertTransform(obj.calcTransformMatrix());
const clipPathTag = clipPathElements[0].parentElement!;
let clipPathOwner = usingElement;
while (
!exactOwner &&
clipPathOwner.parentElement &&
clipPathOwner.getAttribute('clip-path') !== obj.clipPath
) {
clipPathOwner = clipPathOwner.parentElement;
}
// move the clipPath tag as sibling to the real element that is using it
clipPathOwner.parentElement!.appendChild(clipPathTag!);
// this multiplication order could be opposite.
// but i don't have an svg to test it
// at the first SVG that has a transform on both places and is misplaced
// try to invert this multiplication order
const finalTransform = parseTransformAttribute(
`${clipPathOwner.getAttribute('transform') || ''} ${
clipPathTag.getAttribute('originalTransform') || ''
}`,
);
clipPathTag.setAttribute(
'transform',
`matrix(${finalTransform.join(',')})`,
);
const container = await Promise.all(
clipPathElements.map((clipPathElement) => {
return findTag(clipPathElement)
.fromElement(clipPathElement, this.options, this.cssRules)
.then((enlivedClippath: NotParsedFabricObject) => {
removeTransformMatrixForSvgParsing(enlivedClippath);
enlivedClippath.fillRule = enlivedClippath.clipRule!;
delete enlivedClippath.clipRule;
return enlivedClippath;
});
}),
);
const clipPath =
container.length === 1 ? container[0] : new Group(container);
const gTransform = multiplyTransformMatrices(
objTransformInv,
clipPath.calcTransformMatrix(),
);
if (clipPath.clipPath) {
await this.resolveClipPath(
clipPath,
clipPathOwner,
// this is tricky.
// it tries to differentiate from when clipPaths are inherited by outside groups
// or when are really clipPaths referencing other clipPaths
clipPathTag.getAttribute('clip-path') ? clipPathOwner : undefined,
);
}
const { scaleX, scaleY, angle, skewX, translateX, translateY } =
qrDecompose(gTransform);
clipPath.set({
flipX: false,
flipY: false,
});
clipPath.set({
scaleX,
scaleY,
angle,
skewX,
skewY: 0,
});
clipPath.setPositionByOrigin(
new Point(translateX, translateY),
CENTER,
CENTER,
);
obj.clipPath = clipPath;
} else {
// if clip-path does not resolve to any element, delete the property.
delete obj.clipPath;
return;
}
}
}