2017-09-19 21:04:42 -04:00
|
|
|
// pathfinder/client/src/svg-loader.ts
|
|
|
|
//
|
|
|
|
// Copyright © 2017 The Pathfinder Project Developers.
|
|
|
|
//
|
|
|
|
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
|
|
|
|
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
|
|
|
|
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
|
|
|
|
// option. This file may not be copied, modified, or distributed
|
|
|
|
// except according to those terms.
|
|
|
|
|
2017-10-02 18:17:21 -04:00
|
|
|
import * as base64js from 'base64-js';
|
2017-09-19 21:04:42 -04:00
|
|
|
import * as glmatrix from 'gl-matrix';
|
|
|
|
import * as _ from 'lodash';
|
|
|
|
|
2017-09-19 23:19:53 -04:00
|
|
|
import 'path-data-polyfill.js';
|
2017-10-02 19:31:54 -04:00
|
|
|
import {parseServerTiming, PathfinderMeshData} from "./meshes";
|
2017-11-01 19:09:58 -04:00
|
|
|
import {AlphaMaskCompositingOperation, RenderTask, RenderTaskType} from './render-task';
|
2017-10-31 20:04:06 -04:00
|
|
|
import {panic, Range, unwrapNull, unwrapUndef} from "./utils";
|
2017-09-19 23:19:53 -04:00
|
|
|
|
|
|
|
export const BUILTIN_SVG_URI: string = "/svg/demo";
|
2017-09-19 21:04:42 -04:00
|
|
|
|
2017-10-30 16:34:55 -04:00
|
|
|
const parseColor = require('parse-color');
|
|
|
|
|
2017-09-19 21:04:42 -04:00
|
|
|
const PARTITION_SVG_PATHS_ENDPOINT_URL: string = "/partition-svg-paths";
|
|
|
|
|
|
|
|
/// The minimum size of a stroke.
|
|
|
|
const HAIRLINE_STROKE_WIDTH: number = 0.25;
|
|
|
|
|
2017-09-19 23:19:53 -04:00
|
|
|
declare class SVGPathSegment {
|
|
|
|
type: string;
|
|
|
|
values: number[];
|
|
|
|
}
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
interface SVGPathElement {
|
|
|
|
getPathData(settings: any): SVGPathSegment[];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-10-31 17:50:15 -04:00
|
|
|
export abstract class SVGPath {
|
2017-09-19 21:04:42 -04:00
|
|
|
element: SVGPathElement;
|
2017-10-31 17:50:15 -04:00
|
|
|
color: glmatrix.vec4;
|
|
|
|
|
|
|
|
constructor(element: SVGPathElement, colorProperty: keyof CSSStyleDeclaration) {
|
|
|
|
this.element = element;
|
|
|
|
|
|
|
|
const style = window.getComputedStyle(element);
|
2017-10-31 20:04:06 -04:00
|
|
|
this.color = unwrapNull(colorFromStyle(style[colorProperty]));
|
2017-10-31 17:50:15 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class SVGFill extends SVGPath {
|
|
|
|
constructor(element: SVGPathElement) {
|
|
|
|
super(element, 'fill');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class SVGStroke extends SVGPath {
|
|
|
|
width: number;
|
|
|
|
|
|
|
|
constructor(element: SVGPathElement) {
|
|
|
|
super(element, 'stroke');
|
|
|
|
|
|
|
|
const style = window.getComputedStyle(element);
|
|
|
|
this.width = parseInt(style.strokeWidth!, 10);
|
|
|
|
}
|
2017-09-19 21:04:42 -04:00
|
|
|
}
|
|
|
|
|
2017-10-31 20:04:06 -04:00
|
|
|
interface ClipPathIDTable {
|
|
|
|
[id: string]: number;
|
|
|
|
}
|
|
|
|
|
2017-09-20 01:27:31 -04:00
|
|
|
export class SVGLoader {
|
2017-11-01 19:09:58 -04:00
|
|
|
renderTasks: RenderTask[];
|
2017-10-31 17:50:15 -04:00
|
|
|
pathInstances: SVGPath[];
|
2017-09-28 17:34:48 -04:00
|
|
|
scale: number;
|
|
|
|
bounds: glmatrix.vec4;
|
|
|
|
|
|
|
|
private svg: SVGSVGElement;
|
|
|
|
private fileData: ArrayBuffer;
|
|
|
|
|
|
|
|
private paths: any[];
|
2017-10-31 20:04:06 -04:00
|
|
|
private clipPathIDs: ClipPathIDTable;
|
2017-09-28 17:34:48 -04:00
|
|
|
|
2017-09-19 21:04:42 -04:00
|
|
|
constructor() {
|
2017-09-20 01:27:31 -04:00
|
|
|
this.scale = 1.0;
|
2017-10-31 20:04:06 -04:00
|
|
|
this.renderTasks = [];
|
2017-09-19 21:04:42 -04:00
|
|
|
this.pathInstances = [];
|
2017-09-19 23:19:53 -04:00
|
|
|
this.paths = [];
|
2017-09-19 21:04:42 -04:00
|
|
|
this.bounds = glmatrix.vec4.create();
|
2017-10-31 20:04:06 -04:00
|
|
|
this.svg = unwrapNull(document.getElementById('pf-svg')) as Element as SVGSVGElement;
|
2017-09-19 21:04:42 -04:00
|
|
|
}
|
|
|
|
|
2017-09-19 23:19:53 -04:00
|
|
|
loadFile(fileData: ArrayBuffer) {
|
2017-09-19 21:04:42 -04:00
|
|
|
this.fileData = fileData;
|
|
|
|
|
|
|
|
const decoder = new (window as any).TextDecoder('utf-8');
|
|
|
|
const fileStringData = decoder.decode(new DataView(this.fileData));
|
|
|
|
const svgDocument = (new DOMParser).parseFromString(fileStringData, 'image/svg+xml');
|
|
|
|
const svgElement = svgDocument.documentElement as Element as SVGSVGElement;
|
2017-09-19 23:19:53 -04:00
|
|
|
this.attachSVG(svgElement);
|
2017-09-19 21:04:42 -04:00
|
|
|
}
|
|
|
|
|
2017-09-28 17:34:48 -04:00
|
|
|
partition(pathIndex?: number | undefined): Promise<PathfinderMeshData> {
|
|
|
|
// Make the request.
|
|
|
|
const paths = pathIndex == null ? this.paths : [this.paths[pathIndex]];
|
2017-10-02 19:31:54 -04:00
|
|
|
let time = 0;
|
2017-09-28 17:34:48 -04:00
|
|
|
return window.fetch(PARTITION_SVG_PATHS_ENDPOINT_URL, {
|
|
|
|
body: JSON.stringify({ paths: paths }),
|
2017-11-17 20:10:05 -05:00
|
|
|
headers: {'Content-Type': 'application/json'} as any,
|
2017-09-28 17:34:48 -04:00
|
|
|
method: 'POST',
|
2017-10-02 19:31:54 -04:00
|
|
|
}).then(response => {
|
|
|
|
time = parseServerTiming(response.headers);
|
|
|
|
return response.arrayBuffer();
|
|
|
|
}).then(buffer => new PathfinderMeshData(buffer));
|
2017-09-28 17:34:48 -04:00
|
|
|
}
|
|
|
|
|
2017-09-19 23:19:53 -04:00
|
|
|
private attachSVG(svgElement: SVGSVGElement) {
|
2017-09-19 21:04:42 -04:00
|
|
|
// Clear out the current document.
|
|
|
|
let kid;
|
|
|
|
while ((kid = this.svg.firstChild) != null)
|
|
|
|
this.svg.removeChild(kid);
|
|
|
|
|
|
|
|
// Add all kids of the incoming SVG document.
|
|
|
|
while ((kid = svgElement.firstChild) != null)
|
|
|
|
this.svg.appendChild(kid);
|
|
|
|
|
|
|
|
// Scan for geometry elements.
|
2017-10-31 20:04:06 -04:00
|
|
|
this.renderTasks.length = 0;
|
2017-09-19 21:04:42 -04:00
|
|
|
this.pathInstances.length = 0;
|
2017-10-31 20:04:06 -04:00
|
|
|
this.clipPathIDs = {};
|
|
|
|
this.pushNewRenderTask('color');
|
|
|
|
this.scanElement(this.svg);
|
|
|
|
this.popTopRenderTaskIfEmpty();
|
2017-09-19 21:04:42 -04:00
|
|
|
|
|
|
|
let minX = 0, minY = 0, maxX = 0, maxY = 0;
|
2017-09-19 23:19:53 -04:00
|
|
|
this.paths = [];
|
2017-09-19 21:04:42 -04:00
|
|
|
|
|
|
|
// Extract, normalize, and transform the path data.
|
|
|
|
for (const instance of this.pathInstances) {
|
|
|
|
const element = instance.element;
|
|
|
|
const svgCTM = element.getCTM();
|
|
|
|
const ctm = glmatrix.mat2d.fromValues(svgCTM.a, svgCTM.b,
|
|
|
|
svgCTM.c, svgCTM.d,
|
|
|
|
svgCTM.e, svgCTM.f);
|
|
|
|
glmatrix.mat2d.scale(ctm, ctm, [1.0, -1.0]);
|
2017-09-20 01:27:31 -04:00
|
|
|
glmatrix.mat2d.scale(ctm, ctm, [this.scale, this.scale]);
|
2017-09-19 21:04:42 -04:00
|
|
|
|
2017-10-31 20:04:06 -04:00
|
|
|
const segments = element.getPathData({ normalize: true }).map(segment => {
|
2017-09-19 21:04:42 -04:00
|
|
|
const newValues = _.flatMap(_.chunk(segment.values, 2), coords => {
|
|
|
|
const point = glmatrix.vec2.create();
|
|
|
|
glmatrix.vec2.transformMat2d(point, coords, ctm);
|
|
|
|
|
|
|
|
minX = Math.min(point[0], minX);
|
|
|
|
minY = Math.min(point[1], minY);
|
|
|
|
maxX = Math.max(point[0], maxX);
|
|
|
|
maxY = Math.max(point[1], maxY);
|
|
|
|
|
|
|
|
return [point[0], point[1]];
|
|
|
|
});
|
|
|
|
return {
|
|
|
|
type: segment.type,
|
|
|
|
values: newValues,
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
2017-10-31 17:50:15 -04:00
|
|
|
if (instance instanceof SVGFill) {
|
|
|
|
this.paths.push({ segments: segments, kind: 'Fill' });
|
|
|
|
} else if (instance instanceof SVGStroke) {
|
|
|
|
this.paths.push({
|
|
|
|
kind: { Stroke: Math.max(HAIRLINE_STROKE_WIDTH, instance.width) },
|
|
|
|
segments: segments,
|
|
|
|
});
|
|
|
|
}
|
2017-09-19 21:04:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
this.bounds = glmatrix.vec4.clone([minX, minY, maxX, maxY]);
|
2017-09-19 23:19:53 -04:00
|
|
|
}
|
2017-10-31 20:04:06 -04:00
|
|
|
|
|
|
|
private scanElement(element: Element): void {
|
|
|
|
const currentRenderTask = unwrapUndef(_.last(this.renderTasks));
|
|
|
|
const style = window.getComputedStyle(element);
|
|
|
|
|
|
|
|
let hasClip = style.clipPath != null && style.clipPath !== 'none';
|
|
|
|
if (hasClip) {
|
|
|
|
const matches = /^url\("#([^"]+)"\)$/.exec(unwrapNull(style.clipPath));
|
|
|
|
if (matches == null ||
|
|
|
|
matches[1] == null ||
|
|
|
|
!this.clipPathIDs.hasOwnProperty(matches[1])) {
|
|
|
|
hasClip = false;
|
|
|
|
} else {
|
2017-11-01 19:09:58 -04:00
|
|
|
currentRenderTask.compositingOperation =
|
|
|
|
new AlphaMaskCompositingOperation(this.clipPathIDs[matches[1]]);
|
2017-10-31 20:04:06 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (element instanceof SVGPathElement) {
|
|
|
|
if (colorFromStyle(style.fill) != null)
|
|
|
|
this.addPathInstance(new SVGFill(element));
|
|
|
|
if (colorFromStyle(style.stroke) != null)
|
|
|
|
this.addPathInstance(new SVGStroke(element));
|
|
|
|
}
|
|
|
|
|
|
|
|
if (element instanceof SVGClipPathElement) {
|
|
|
|
this.pushNewRenderTask('clip');
|
|
|
|
this.clipPathIDs[element.id] = this.renderTasks.length - 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const kid of element.childNodes) {
|
|
|
|
if (kid instanceof Element)
|
|
|
|
this.scanElement(kid);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (element instanceof SVGClipPathElement || hasClip)
|
|
|
|
this.pushNewRenderTask('color');
|
|
|
|
}
|
|
|
|
|
|
|
|
private addPathInstance(pathInstance: SVGPath): void {
|
|
|
|
const currentRenderTask = unwrapUndef(_.last(this.renderTasks));
|
|
|
|
this.pathInstances.push(pathInstance);
|
|
|
|
currentRenderTask.instanceIndices.end = Math.max(currentRenderTask.instanceIndices.end,
|
2017-11-01 19:09:58 -04:00
|
|
|
this.pathInstances.length + 1);
|
2017-10-31 20:04:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
private popTopRenderTaskIfEmpty(): void {
|
|
|
|
const lastRenderTask = _.last(this.renderTasks);
|
|
|
|
if (lastRenderTask != null && lastRenderTask.instanceIndices.isEmpty)
|
|
|
|
this.renderTasks.pop();
|
|
|
|
}
|
|
|
|
|
2017-11-01 19:09:58 -04:00
|
|
|
private pushNewRenderTask(taskType: RenderTaskType): void {
|
2017-10-31 20:04:06 -04:00
|
|
|
this.popTopRenderTaskIfEmpty();
|
2017-11-01 19:09:58 -04:00
|
|
|
const emptyRange = new Range(this.pathInstances.length + 1, this.pathInstances.length + 1);
|
|
|
|
this.renderTasks.push(new RenderTask(taskType, emptyRange));
|
2017-10-31 20:04:06 -04:00
|
|
|
}
|
2017-09-19 21:04:42 -04:00
|
|
|
}
|
2017-10-30 16:34:55 -04:00
|
|
|
|
2017-10-31 20:04:06 -04:00
|
|
|
function colorFromStyle(style: string | null): glmatrix.vec4 | null {
|
|
|
|
if (style == null || style === 'none')
|
|
|
|
return null;
|
|
|
|
|
|
|
|
// TODO(pcwalton): Gradients?
|
|
|
|
const color = parseColor(style);
|
|
|
|
if (color.rgba == null)
|
|
|
|
return glmatrix.vec4.clone([0.0, 0.0, 0.0, 1.0]);
|
|
|
|
|
|
|
|
if (color.rgba[3] === 0.0)
|
|
|
|
return null;
|
|
|
|
return glmatrix.vec4.clone(color.rgba);
|
2017-10-30 16:34:55 -04:00
|
|
|
}
|