Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions .codesandbox/templates/node/src/index.mjs
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
import http from 'http';
import { Canvas } from 'canvas';
import * as fabric from 'fabric/node';
import http from 'http';

const port = Number(process.argv[2]);

fabric.FabricObject.ownDefaults.objectCaching = false;

http
.createServer((req, res) => {
const canvas = new fabric.StaticCanvas(null, { width: 100, height: 100 });
const rect = new fabric.Rect({ width: 20, height: 50, fill: '#ff0000' });
const text = new fabric.Text('fabric.js', { fill: 'blue', fontSize: 24 });
const canvas = new fabric.StaticCanvas(null, { width: 1000, height: 1000 });
const rect = new fabric.Rect({ width: 50, height: 50, fill: 'red' });
const path = fabric.util.getSmoothPathFromPoints([
new fabric.Point(50, 50),
new fabric.Point(100, 100),
new fabric.Point(50, 200),
new fabric.Point(400, 150),
new fabric.Point(500, 500),
]);
const text = new fabric.FabricText(
new Array(9).fill('fabric.js').join(' '),
{
fill: 'blue',
fontSize: 24,
path: new fabric.Path(path),
}
);
canvas.add(rect, text);
canvas.renderAll();
const pdf = canvas.toCanvasElement(
1,
{
width: 460,
height: 450,
},
new Canvas(0, 0, 'pdf').getContext('2d')
);
const svg = canvas.toCanvasElement(
1,
{
width: 460,
height: 450,
},
new Canvas(0, 0, 'svg').getContext('2d')
);
const svg2 = text.toCanvasElement({
canvasElement: new Canvas(1000, 1000, 'svg').getContext('2d'),
});
if (req.url === '/download') {
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Disposition', 'attachment; filename="fabric.png"');
canvas.createPNGStream().pipe(res);
res.setHeader('Content-Disposition', 'attachment; filename="fabric.pdf"');
pdf.createPDFStream().pipe(res);
} else if (req.url === '/view') {
canvas.createPNGStream().pipe(res);
pdf.createPDFStream().pipe(res);
} else {
const imageData = canvas.toDataURL();
res.writeHead(200, '', { 'Content-Type': 'text/html' });
res.write(`<img src="${imageData}" />`);
res.writeHead(200, '', { 'Content-Type': 'application/pdf' });
res.write(pdf.toBuffer());
res.end();
}
})
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
- fix(CanvasEvents): regression of `getPointer` usages + BREAKING: drop event data [#9186](https://github.com/fabricjs/fabric.js/pull/9186)
- feat(Object): BREAKING rm \_setOriginToCenter and \_resetOrigin unuseful methods [#9179](https://github.com/fabricjs/fabric.js/pull/9179)
- fix(ActiveSelection): reset positioning when cleared [#9088](https://github.com/fabricjs/fabric.js/pull/9088)
- feat(Node): pdf/svg output [#9185](https://github.com/fabricjs/fabric.js/pull/9185)
- ci(): generate docs [#9169](https://github.com/fabricjs/fabric.js/pull/9169)
- fix(utils) Fixes the code for the anchor point in point controls for polygons [#9178](https://github.com/fabricjs/fabric.js/pull/9178)
- CD(): website submodule [#9165](https://github.com/fabricjs/fabric.js/pull/9165)
Expand Down
136 changes: 136 additions & 0 deletions e2e/tests/export-node-canvas/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Page, TestInfo, expect, test } from '@playwright/test';
import { Canvas } from 'canvas';
import * as fabric from 'fabric/node';
import { createWriteStream, writeFileSync } from 'fs';
import { ensureDirSync } from 'fs-extra';
import path from 'path';

import '../../setup/setupCoverage';

test.describe('Exporting node canvas', () => {
const size = {
width: 460,
height: 450,
};

const createCanvas = () => {
const canvas = new fabric.StaticCanvas(null, {
width: 1000,
height: 1000,
enableRetinaScaling: false,
});
const rect = new fabric.Rect({ width: 50, height: 50, fill: 'red' });
const path = fabric.util.getSmoothPathFromPoints([
new fabric.Point(50, 50),
new fabric.Point(100, 100),
new fabric.Point(50, 200),
new fabric.Point(400, 150),
new fabric.Point(500, 500),
]);
const text = new fabric.Text(new Array(9).fill('fabric.js').join(' '), {
fill: 'blue',
fontSize: 24,
path: new fabric.Path(path),
});
canvas.add(rect, text);
return canvas;
};

const createCtxForExport = (type: 'pdf' | 'svg') => {
const ctx = new Canvas(0, 0, type).getContext('2d');
ctx.textDrawingMode = 'glyph';
return ctx;
};

test.describe('SVG', () => {
const attachSVG = async (result: Canvas, testInfo: TestInfo) => {
ensureDirSync(testInfo.outputDir);
const pathTo = path.resolve(testInfo.outputDir, 'output.svg');
writeFileSync(pathTo, result.toBuffer());
await testInfo.attach('output', {
path: pathTo,
});
};

const testSVG = async (page: Page, result: Canvas) => {
await page.goto(
`data:image/svg+xml,${encodeURIComponent(result.toBuffer().toString())
.replace(/'/g, '%27')
.replace(/"/g, '%22')}`,
{ waitUntil: 'load' }
);
expect(
await page.screenshot({
clip: { x: 0, y: 0, width: 460, height: 450 },
})
).toMatchSnapshot();
};

test('canvas', async ({ page }, testInfo) => {
const ctx = createCtxForExport('svg');
const result = createCanvas().toCanvasElement(1, size, ctx);
expect(result).toMatchObject(size);
await attachSVG(result, testInfo);
await testSVG(page, result);
});

test('object', async ({ page }, testInfo) => {
const ctx = createCtxForExport('svg');
const result = createCanvas()
.item(1)
.toCanvasElement({ ...size, ctx });
expect(result).toMatchObject(size);
await attachSVG(result, testInfo);
await testSVG(page, result);
});
});

test.describe('PDF', () => {
const attachPDF = async (result: Canvas, testInfo: TestInfo) => {
ensureDirSync(testInfo.outputDir);
const pathTo = path.resolve(testInfo.outputDir, 'output.pdf');
const out = createWriteStream(pathTo);
await new Promise((resolve) => {
out.on('finish', resolve);
result.createPDFStream().pipe(out);
});
await testInfo.attach('output', {
path: pathTo,
contentType: 'application/pdf',
});
};

const testPDF = async (page: Page, result: Canvas) => {
await page.goto(
`data:application/pdf;base64,${Buffer.from(result.toBuffer()).toString(
'base64'
)}`,
{ waitUntil: 'load' }
);
await page.waitForTimeout(5000);
expect(
await page.screenshot({
clip: { x: 80, y: 25, width: 500, height: 500 },
})
).toMatchSnapshot();
};

test('canvas', async ({ page }, testInfo) => {
const ctx = createCtxForExport('pdf');
const result = createCanvas().toCanvasElement(1, size, ctx);
expect(result).toMatchObject(size);
await attachPDF(result, testInfo);
await testPDF(page, result);
});

test('object', async ({ page }, testInfo) => {
const ctx = createCtxForExport('pdf');
const result = createCanvas()
.item(1)
.toCanvasElement({ ...size, ctx });
expect(result).toMatchObject(size);
await attachPDF(result, testInfo);
await testPDF(page, result);
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 16 additions & 15 deletions src/canvas/StaticCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1378,16 +1378,16 @@ export class StaticCanvas<
* @example <caption>Generate dataURL with objects that overlap a specified object</caption>
* var myObject;
* var dataURL = canvas.toDataURL({
* filter: (object) => object.isContainedWithinObject(myObject) || object.intersectsWithObject(myObject)
* filter: (object) => object.isOverlapping(myObject)
* });
*/
toDataURL(options = {} as TDataUrlOptions): string {
const {
format = 'png',
quality = 1,
multiplier = 1,
enableRetinaScaling = false,
} = options;
toDataURL({
format = 'png',
quality = 1,
multiplier = 1,
enableRetinaScaling = false,
...options
}: TDataUrlOptions = {}): string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to me it doens't make sense to specify a ctx here.
Can we releage this to a specific node item?
If we need to export something in PDF/SVG using node-canvas, shouldn't we add a method that is specific to that and return something related to that?

We don't have code size issues in node and we are not bound to bundling issues.
Having toCanvasElement that return a canvas element that is good for pdf/svg export buy only if we previously created a specific ctx sounds complicated.

Let's have a toNodePDF and a toNodeSVG ( or whatever it can be called to avoid confusion with current toSVG ) in the node classes.

Take also care that moving away from node-canvas is still a remote option and if there are other libraries that do not require building or that are more canvas compliant, is not granted they will have a pdf or svg export.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is why I didn't expose toPDF etc. So we are not bound to node canvas.

Suggest a different way?

const finalMultiplier =
multiplier * (enableRetinaScaling ? this.getRetinaScaling() : 1);

Expand All @@ -1405,16 +1405,18 @@ export class StaticCanvas<
* This is an intermediary step used to get to a dataUrl but also it is useful to
* create quick image copies of a canvas without passing for the dataUrl string
* @param {Number} [multiplier] a zoom factor.
* @param {Object} [options] Cropping informations
* @param {Object} [options] Cropping information.
* @param {Number} [options.left] Cropping left offset.
* @param {Number} [options.top] Cropping top offset.
* @param {Number} [options.width] Cropping width.
* @param {Number} [options.height] Cropping height.
* @param {(object: fabric.Object) => boolean} [options.filter] Function to filter objects.
* @param {CanvasRenderingContext2D} [ctx] Supports passing a pdf/svg ctx to in node, see https://github.com/Automattic/node-canvas#createcanvas.
*/
toCanvasElement(
multiplier = 1,
{ width, height, left, top, filter } = {} as TToCanvasElementOptions
{ width, height, left, top, filter }: TToCanvasElementOptions = {},
ctx = createCanvasElement().getContext('2d')!
): HTMLCanvasElement {
const scaledWidth = (width || this.width) * multiplier,
scaledHeight = (height || this.height) * multiplier,
Expand All @@ -1427,24 +1429,23 @@ export class StaticCanvas<
translateY = (vp[5] - (top || 0)) * multiplier,
newVp = [newZoom, 0, 0, newZoom, translateX, translateY] as TMat2D,
originalRetina = this.enableRetinaScaling,
canvasEl = createCanvasElement(),
objectsToRender = filter
? this._objects.filter((obj) => filter(obj))
: this._objects;
canvasEl.width = scaledWidth;
canvasEl.height = scaledHeight;
ctx.canvas.width = scaledWidth;
ctx.canvas.height = scaledHeight;
this.enableRetinaScaling = false;
this.viewportTransform = newVp;
this.width = scaledWidth;
this.height = scaledHeight;
this.calcViewportBoundaries();
this.renderCanvas(canvasEl.getContext('2d')!, objectsToRender);
this.renderCanvas(ctx, objectsToRender);
this.viewportTransform = vp;
this.width = originalWidth;
this.height = originalHeight;
this.calcViewportBoundaries();
this.enableRetinaScaling = originalRetina;
return canvasEl;
return ctx.canvas;
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/shapes/IText/IText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
keysMap,
keysMapRtl,
} from './constants';
import type { TFiller, TOptions } from '../../typedefs';
import type {
ObjectToCanvasElementOptions,
TFiller,
TOptions,
} from '../../typedefs';
import { classRegistry } from '../../ClassRegistry';
import type { SerializedTextProps, TextProps } from '../Text/Text';
import {
Expand Down Expand Up @@ -354,7 +358,7 @@ export class IText<
* @override block cursor/selection logic while rendering the exported canvas
* @todo this workaround should be replaced with a more robust solution
*/
toCanvasElement(options?: any): HTMLCanvasElement {
toCanvasElement(options?: ObjectToCanvasElementOptions): HTMLCanvasElement {
const isEditing = this.isEditing;
this.isEditing = false;
const canvas = super.toCanvasElement(options);
Expand Down
Loading