|
@@ -0,0 +1,408 @@
|
|
|
+import {
|
|
|
+ DataUri,
|
|
|
+ NumberExt,
|
|
|
+ FunctionExt,
|
|
|
+ Vector,
|
|
|
+ Rectangle,
|
|
|
+ Size,
|
|
|
+ KeyValue,
|
|
|
+ Basecoat,
|
|
|
+ Dom,
|
|
|
+ Graph,
|
|
|
+} from '@antv/x6'
|
|
|
+import './api'
|
|
|
+
|
|
|
+export class Export extends Basecoat<Export.EventArgs> implements Graph.Plugin {
|
|
|
+ public name = 'export'
|
|
|
+ private graph: Graph
|
|
|
+
|
|
|
+ constructor() {
|
|
|
+ super()
|
|
|
+ }
|
|
|
+
|
|
|
+ get view() {
|
|
|
+ return this.graph.view
|
|
|
+ }
|
|
|
+
|
|
|
+ init(graph: Graph) {
|
|
|
+ this.graph = graph
|
|
|
+ }
|
|
|
+
|
|
|
+ exportPNG(fileName = 'chart', options: Export.ToImageOptions = {}) {
|
|
|
+ this.toPNG((dataUri) => {
|
|
|
+ DataUri.downloadDataUri(dataUri, fileName)
|
|
|
+ }, options)
|
|
|
+ }
|
|
|
+
|
|
|
+ exportJPEG(fileName = 'chart', options: Export.ToImageOptions = {}) {
|
|
|
+ this.toJPEG((dataUri) => {
|
|
|
+ DataUri.downloadDataUri(dataUri, fileName)
|
|
|
+ }, options)
|
|
|
+ }
|
|
|
+
|
|
|
+ exportSVG(fileName = 'chart', options: Export.ToSVGOptions = {}) {
|
|
|
+ this.toSVG((svg: string) => {
|
|
|
+ DataUri.downloadDataUri(DataUri.svgToDataUrl(svg), fileName)
|
|
|
+ }, options)
|
|
|
+ }
|
|
|
+
|
|
|
+ toSVG(callback: Export.ToSVGCallback, options: Export.ToSVGOptions = {}) {
|
|
|
+ this.notify('before:export', options)
|
|
|
+
|
|
|
+ // to keep pace with it's doc description witch, the default value should be true.
|
|
|
+ // without Object.hasOwn method cause by ts config target.
|
|
|
+ // without instance.hasOwnProperty method cause by ts rule.
|
|
|
+ // the condition will be false if these properties have been set undefined in the target,
|
|
|
+ // but will be true if these properties are not in the target, cause the doc.
|
|
|
+ !Object.prototype.hasOwnProperty.call(options, 'copyStyle') &&
|
|
|
+ (options.copyStyles = true)
|
|
|
+ !Object.prototype.hasOwnProperty.call(options, 'serializeImages') &&
|
|
|
+ (options.serializeImages = true)
|
|
|
+
|
|
|
+ const rawSVG = this.view.svg
|
|
|
+ const vSVG = Vector.create(rawSVG).clone()
|
|
|
+ let clonedSVG = vSVG.node as SVGSVGElement
|
|
|
+ const vStage = vSVG.findOne(
|
|
|
+ `.${this.view.prefixClassName('graph-svg-stage')}`,
|
|
|
+ )!
|
|
|
+
|
|
|
+ const viewBox =
|
|
|
+ options.viewBox || this.graph.graphToLocal(this.graph.getContentBBox())
|
|
|
+ const dimension = options.preserveDimensions
|
|
|
+ if (dimension) {
|
|
|
+ const size = typeof dimension === 'boolean' ? viewBox : dimension
|
|
|
+ vSVG.attr({
|
|
|
+ width: size.width,
|
|
|
+ height: size.height,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ vSVG
|
|
|
+ .removeAttribute('style')
|
|
|
+ .attr(
|
|
|
+ 'viewBox',
|
|
|
+ [viewBox.x, viewBox.y, viewBox.width, viewBox.height].join(' '),
|
|
|
+ )
|
|
|
+
|
|
|
+ vStage.removeAttribute('transform')
|
|
|
+
|
|
|
+ // Stores all the CSS declarations from external stylesheets to the
|
|
|
+ // `style` attribute of the SVG document nodes.
|
|
|
+
|
|
|
+ // This is achieved in three steps.
|
|
|
+ // -----------------------------------
|
|
|
+
|
|
|
+ // 1. Disabling all the stylesheets in the page and therefore collecting
|
|
|
+ // only default style values. This, together with the step 2, makes it
|
|
|
+ // possible to discard default CSS property values and store only those
|
|
|
+ // that differ.
|
|
|
+ //
|
|
|
+ // 2. Enabling back all the stylesheets in the page and collecting styles
|
|
|
+ // that differ from the default values.
|
|
|
+ //
|
|
|
+ // 3. Applying the difference between default values and the ones set by
|
|
|
+ // custom stylesheets onto the `style` attribute of each of the nodes
|
|
|
+ // in SVG.
|
|
|
+
|
|
|
+ if (options.copyStyles) {
|
|
|
+ const document = rawSVG.ownerDocument!
|
|
|
+ const raws = Array.from(rawSVG.querySelectorAll('*'))
|
|
|
+ const clones = Array.from(clonedSVG.querySelectorAll('*'))
|
|
|
+
|
|
|
+ const styleSheetCount = document.styleSheets.length
|
|
|
+ const styleSheetsCopy = []
|
|
|
+ for (let k = styleSheetCount - 1; k >= 0; k -= 1) {
|
|
|
+ // There is a bug (bugSS) in Chrome 14 and Safari. When you set
|
|
|
+ // `stylesheet.disable = true` it will also remove it from
|
|
|
+ // `document.styleSheets`. So we need to store all stylesheets before
|
|
|
+ // we disable them. Later on we put them back to `document.styleSheets`
|
|
|
+ // if needed.
|
|
|
+
|
|
|
+ // See the bug `https://code.google.com/p/chromium/issues/detail?id=88310`.
|
|
|
+ styleSheetsCopy[k] = document.styleSheets[k]
|
|
|
+ document.styleSheets[k].disabled = true
|
|
|
+ }
|
|
|
+
|
|
|
+ const defaultComputedStyles: KeyValue<KeyValue<string>> = {}
|
|
|
+ raws.forEach((elem, index) => {
|
|
|
+ const computedStyle = window.getComputedStyle(elem, null)
|
|
|
+ // We're making a deep copy of the `computedStyle` so that it's not affected
|
|
|
+ // by that next step when all the stylesheets are re-enabled again.
|
|
|
+ const defaultComputedStyle: KeyValue<string> = {}
|
|
|
+ Object.keys(computedStyle).forEach((property) => {
|
|
|
+ defaultComputedStyle[property] =
|
|
|
+ computedStyle.getPropertyValue(property)
|
|
|
+ })
|
|
|
+
|
|
|
+ defaultComputedStyles[index] = defaultComputedStyle
|
|
|
+ })
|
|
|
+
|
|
|
+ // Copy all stylesheets back
|
|
|
+ if (styleSheetCount !== document.styleSheets.length) {
|
|
|
+ styleSheetsCopy.forEach((copy, index) => {
|
|
|
+ document.styleSheets[index] = copy
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let i = 0; i < styleSheetCount; i += 1) {
|
|
|
+ document.styleSheets[i].disabled = false
|
|
|
+ }
|
|
|
+
|
|
|
+ const customStyles: KeyValue<KeyValue<string>> = {}
|
|
|
+ raws.forEach((elem, index) => {
|
|
|
+ const computedStyle = window.getComputedStyle(elem, null)
|
|
|
+ const defaultComputedStyle = defaultComputedStyles[index]
|
|
|
+ const customStyle: KeyValue<string> = {}
|
|
|
+
|
|
|
+ Object.keys(computedStyle).forEach((property) => {
|
|
|
+ if (
|
|
|
+ !NumberExt.isNumber(property) &&
|
|
|
+ computedStyle.getPropertyValue(property) !==
|
|
|
+ defaultComputedStyle[property]
|
|
|
+ ) {
|
|
|
+ customStyle[property] = computedStyle.getPropertyValue(property)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ customStyles[index] = customStyle
|
|
|
+ })
|
|
|
+
|
|
|
+ clones.forEach((elem, index) => {
|
|
|
+ Dom.css(elem, customStyles[index])
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const stylesheet = options.stylesheet
|
|
|
+ if (typeof stylesheet === 'string') {
|
|
|
+ const cDATASection = rawSVG
|
|
|
+ .ownerDocument!.implementation.createDocument(null, 'xml', null)
|
|
|
+ .createCDATASection(stylesheet)
|
|
|
+
|
|
|
+ vSVG.prepend(
|
|
|
+ Vector.create(
|
|
|
+ 'style',
|
|
|
+ {
|
|
|
+ type: 'text/css',
|
|
|
+ },
|
|
|
+ [cDATASection as any],
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const format = () => {
|
|
|
+ const beforeSerialize = options.beforeSerialize
|
|
|
+ if (typeof beforeSerialize === 'function') {
|
|
|
+ const ret = FunctionExt.call(beforeSerialize, this.graph, clonedSVG)
|
|
|
+ if (ret instanceof SVGSVGElement) {
|
|
|
+ clonedSVG = ret
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const dataUri = new XMLSerializer()
|
|
|
+ .serializeToString(clonedSVG)
|
|
|
+ .replace(/ /g, '\u00a0')
|
|
|
+
|
|
|
+ this.notify('after:export', options)
|
|
|
+ callback(dataUri)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (options.serializeImages) {
|
|
|
+ const deferrals = vSVG.find('image').map((vImage) => {
|
|
|
+ return new Promise<void>((resolve) => {
|
|
|
+ const url = vImage.attr('xlink:href') || vImage.attr('href')
|
|
|
+ DataUri.imageToDataUri(url, (err, dataUri) => {
|
|
|
+ if (!err && dataUri) {
|
|
|
+ vImage.attr('xlink:href', dataUri)
|
|
|
+ }
|
|
|
+ resolve()
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ Promise.all(deferrals).then(format)
|
|
|
+ } else {
|
|
|
+ format()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ toDataURL(callback: Export.ToSVGCallback, options: Export.ToDataURLOptions) {
|
|
|
+ let viewBox = options.viewBox || this.graph.getContentBBox()
|
|
|
+
|
|
|
+ const padding = NumberExt.normalizeSides(options.padding)
|
|
|
+ if (options.width && options.height) {
|
|
|
+ if (padding.left + padding.right >= options.width) {
|
|
|
+ padding.left = padding.right = 0
|
|
|
+ }
|
|
|
+ if (padding.top + padding.bottom >= options.height) {
|
|
|
+ padding.top = padding.bottom = 0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const expanding = new Rectangle(
|
|
|
+ -padding.left,
|
|
|
+ -padding.top,
|
|
|
+ padding.left + padding.right,
|
|
|
+ padding.top + padding.bottom,
|
|
|
+ )
|
|
|
+
|
|
|
+ if (options.width && options.height) {
|
|
|
+ const width = viewBox.width + padding.left + padding.right
|
|
|
+ const height = viewBox.height + padding.top + padding.bottom
|
|
|
+ expanding.scale(width / options.width, height / options.height)
|
|
|
+ }
|
|
|
+
|
|
|
+ viewBox = Rectangle.create(viewBox).moveAndExpand(expanding)
|
|
|
+
|
|
|
+ const rawSize =
|
|
|
+ typeof options.width === 'number' && typeof options.height === 'number'
|
|
|
+ ? { width: options.width, height: options.height }
|
|
|
+ : viewBox
|
|
|
+
|
|
|
+ let scale = options.ratio ? parseFloat(options.ratio) : 1
|
|
|
+ if (!Number.isFinite(scale) || scale === 0) {
|
|
|
+ scale = 1
|
|
|
+ }
|
|
|
+
|
|
|
+ const size = {
|
|
|
+ width: Math.max(Math.round(rawSize.width * scale), 1),
|
|
|
+ height: Math.max(Math.round(rawSize.height * scale), 1),
|
|
|
+ }
|
|
|
+
|
|
|
+ {
|
|
|
+ const imgDataCanvas = document.createElement('canvas')
|
|
|
+ const context2D = imgDataCanvas.getContext('2d')!
|
|
|
+ imgDataCanvas.width = size.width
|
|
|
+ imgDataCanvas.height = size.height
|
|
|
+ const x = size.width - 1
|
|
|
+ const y = size.height - 1
|
|
|
+ context2D.fillStyle = 'rgb(1,1,1)'
|
|
|
+ context2D.fillRect(x, y, 1, 1)
|
|
|
+ const data = context2D.getImageData(x, y, 1, 1).data
|
|
|
+ if (data[0] !== 1 || data[1] !== 1 || data[2] !== 1) {
|
|
|
+ throw new Error('size exceeded')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const img = new Image()
|
|
|
+ img.onload = () => {
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ canvas.width = size.width
|
|
|
+ canvas.height = size.height
|
|
|
+
|
|
|
+ const context = canvas.getContext('2d')!
|
|
|
+ context.fillStyle = options.backgroundColor || 'white'
|
|
|
+ context.fillRect(0, 0, size.width, size.height)
|
|
|
+
|
|
|
+ try {
|
|
|
+ context.drawImage(img, 0, 0, size.width, size.height)
|
|
|
+ const dataUri = canvas.toDataURL(options.type, options.quality)
|
|
|
+ callback(dataUri)
|
|
|
+ } catch (error) {
|
|
|
+ // pass
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.toSVG(
|
|
|
+ (dataUri) => {
|
|
|
+ img.src = `data:image/svg+xml,${encodeURIComponent(dataUri)}`
|
|
|
+ },
|
|
|
+ {
|
|
|
+ ...options,
|
|
|
+ viewBox,
|
|
|
+ serializeImages: true,
|
|
|
+ preserveDimensions: {
|
|
|
+ ...size,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ toPNG(callback: Export.ToSVGCallback, options: Export.ToImageOptions = {}) {
|
|
|
+ this.toDataURL(callback, {
|
|
|
+ ...options,
|
|
|
+ type: 'image/png',
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ toJPEG(callback: Export.ToSVGCallback, options: Export.ToImageOptions = {}) {
|
|
|
+ this.toDataURL(callback, {
|
|
|
+ ...options,
|
|
|
+ type: 'image/jpeg',
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ protected notify<K extends keyof Export.EventArgs>(
|
|
|
+ name: K,
|
|
|
+ args: Export.EventArgs[K],
|
|
|
+ ) {
|
|
|
+ this.trigger(name, args)
|
|
|
+ this.graph.trigger(name, args)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Basecoat.dispose()
|
|
|
+ dispose(): void {
|
|
|
+ this.off()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export namespace Export {
|
|
|
+ export interface EventArgs {
|
|
|
+ 'before:export': Export.ToSVGOptions
|
|
|
+ 'after:export': Export.ToSVGOptions
|
|
|
+ }
|
|
|
+
|
|
|
+ export type ToSVGCallback = (dataUri: string) => any
|
|
|
+
|
|
|
+ export interface ToSVGOptions {
|
|
|
+ /**
|
|
|
+ * By default, the resulting SVG has set width and height to `100%`.
|
|
|
+ * If you'd like to have the dimensions to be set to the actual content
|
|
|
+ * width and height, set `preserveDimensions` to `true`. An object with
|
|
|
+ * `width` and `height` properties can be also used here if you need to
|
|
|
+ * define the export size explicitely.
|
|
|
+ */
|
|
|
+ preserveDimensions?: boolean | Size
|
|
|
+
|
|
|
+ viewBox?: Rectangle.RectangleLike
|
|
|
+
|
|
|
+ /**
|
|
|
+ * When set to `true` all the styles from external stylesheets are copied
|
|
|
+ * to the resulting SVG export. Note this requires a lot of computations
|
|
|
+ * and it might significantly affect the export time.
|
|
|
+ */
|
|
|
+ copyStyles?: boolean
|
|
|
+
|
|
|
+ stylesheet?: string
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Converts all contained images into Data URI format.
|
|
|
+ */
|
|
|
+ serializeImages?: boolean
|
|
|
+
|
|
|
+ /**
|
|
|
+ * A function called before the XML serialization. It may be used to
|
|
|
+ * modify the exported SVG before it is converted to a string. The
|
|
|
+ * function can also return a new SVGDocument.
|
|
|
+ */
|
|
|
+ beforeSerialize?: (this: Graph, svg: SVGSVGElement) => any
|
|
|
+ }
|
|
|
+
|
|
|
+ export interface ToImageOptions extends ToSVGOptions {
|
|
|
+ /**
|
|
|
+ * The width of the image in pixels.
|
|
|
+ */
|
|
|
+ width?: number
|
|
|
+ /**
|
|
|
+ * The height of the image in pixels.
|
|
|
+ */
|
|
|
+ height?: number
|
|
|
+ ratio?: string
|
|
|
+ backgroundColor?: string
|
|
|
+ padding?: NumberExt.SideOptions
|
|
|
+ quality?: number
|
|
|
+ }
|
|
|
+
|
|
|
+ export interface ToDataURLOptions extends ToImageOptions {
|
|
|
+ type: 'image/png' | 'image/jpeg'
|
|
|
+ }
|
|
|
+}
|