diff --git a/docs/api-composite.md b/docs/api-composite.md index 12cc100ba..ebc38f3a6 100644 --- a/docs/api-composite.md +++ b/docs/api-composite.md @@ -29,6 +29,18 @@ and [https://www.cairographics.org/operators/][2] * `images[].input.create.height` **[Number][7]?** * `images[].input.create.channels` **[Number][7]?** 3-4 * `images[].input.create.background` **([String][6] | [Object][4])?** parsed by the [color][8] module to extract values for red, green, blue and alpha. + * `images[].input.text` **[Object][4]?** describes a new text image to be created. + + * `images[].input.text.text` **[string][6]?** text to render as a UTF-8 string. It can contain Pango markup, for example `LeMonde`. + * `images[].input.text.font` **[string][6]?** font name to render with. + * `images[].input.text.fontfile` **[string][6]?** absolute filesystem path to a font file that can be used by `font`. + * `images[].input.text.width` **[number][7]** integral number of pixels to word-wrap at. Lines of text wider than this will be broken at word boundaries. (optional, default `0`) + * `images[].input.text.height` **[number][7]** integral number of pixels high. When defined, `dpi` will be ignored and the text will automatically fit the pixel resolution defined by `width` and `height`. Will be ignored if `width` is not specified or set to 0. (optional, default `0`) + * `images[].input.text.align` **[string][6]** text alignment (`'left'`, `'centre'`, `'center'`, `'right'`). (optional, default `'left'`) + * `images[].input.text.justify` **[boolean][9]** set this to true to apply justification to the text. (optional, default `false`) + * `images[].input.text.dpi` **[number][7]** the resolution (size) at which to render the text. Does not take effect if `height` is specified. (optional, default `72`) + * `images[].input.text.rgba` **[boolean][9]** set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for pango markup features like `Red!`. (optional, default `false`) + * `images[].input.text.spacing` **[number][7]** text line height in points. Will use the font line height if none is specified. (optional, default `0`) * `images[].blend` **[String][6]** how to blend this image with the image below. (optional, default `'over'`) * `images[].gravity` **[String][6]** gravity at which to place the overlay. (optional, default `'centre'`) * `images[].top` **[Number][7]?** the pixel offset from the top edge. diff --git a/docs/api-constructor.md b/docs/api-constructor.md index b690c5390..52b7df97d 100644 --- a/docs/api-constructor.md +++ b/docs/api-constructor.md @@ -51,6 +51,18 @@ Implements the [stream.Duplex][1] class. * `options.create.noise.type` **[string][12]?** type of generated noise, currently only `gaussian` is supported. * `options.create.noise.mean` **[number][14]?** mean of pixels in generated noise. * `options.create.noise.sigma` **[number][14]?** standard deviation of pixels in generated noise. + * `options.text` **[Object][13]?** describes a new text image to be created. + + * `options.text.text` **[string][12]?** text to render as a UTF-8 string. It can contain Pango markup, for example `LeMonde`. + * `options.text.font` **[string][12]?** font name to render with. + * `options.text.fontfile` **[string][12]?** absolute filesystem path to a font file that can be used by `font`. + * `options.text.width` **[number][14]** integral number of pixels to word-wrap at. Lines of text wider than this will be broken at word boundaries. (optional, default `0`) + * `options.text.height` **[number][14]** integral number of pixels high. When defined, `dpi` will be ignored and the text will automatically fit the pixel resolution defined by `width` and `height`. Will be ignored if `width` is not specified or set to 0. (optional, default `0`) + * `options.text.align` **[string][12]** text alignment (`'left'`, `'centre'`, `'center'`, `'right'`). (optional, default `'left'`) + * `options.text.justify` **[boolean][15]** set this to true to apply justification to the text. (optional, default `false`) + * `options.text.dpi` **[number][14]** the resolution (size) at which to render the text. Does not take effect if `height` is specified. (optional, default `72`) + * `options.text.rgba` **[boolean][15]** set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for pango markup features like `Red!`. (optional, default `false`) + * `options.text.spacing` **[number][14]** text line height in points. Will use the font line height if none is specified. (optional, default `0`) ### Examples diff --git a/lib/composite.js b/lib/composite.js index b6b576bc6..8373767e1 100644 --- a/lib/composite.js +++ b/lib/composite.js @@ -93,6 +93,17 @@ const blend = { * @param {Number} [images[].input.create.height] * @param {Number} [images[].input.create.channels] - 3-4 * @param {String|Object} [images[].input.create.background] - parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. + * @param {Object} [images[].input.text] - describes a new text image to be created. + * @param {string} [images[].input.text.text] - text to render as a UTF-8 string. It can contain Pango markup, for example `LeMonde`. + * @param {string} [images[].input.text.font] - font name to render with. + * @param {string} [images[].input.text.fontfile] - absolute filesystem path to a font file that can be used by `font`. + * @param {number} [images[].input.text.width=0] - integral number of pixels to word-wrap at. Lines of text wider than this will be broken at word boundaries. + * @param {number} [images[].input.text.height=0] - integral number of pixels high. When defined, `dpi` will be ignored and the text will automatically fit the pixel resolution defined by `width` and `height`. Will be ignored if `width` is not specified or set to 0. + * @param {string} [images[].input.text.align='left'] - text alignment (`'left'`, `'centre'`, `'center'`, `'right'`). + * @param {boolean} [images[].input.text.justify=false] - set this to true to apply justification to the text. + * @param {number} [images[].input.text.dpi=72] - the resolution (size) at which to render the text. Does not take effect if `height` is specified. + * @param {boolean} [images[].input.text.rgba=false] - set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for pango markup features like `Red!`. + * @param {number} [images[].input.text.spacing=0] - text line height in points. Will use the font line height if none is specified. * @param {String} [images[].blend='over'] - how to blend this image with the image below. * @param {String} [images[].gravity='centre'] - gravity at which to place the overlay. * @param {Number} [images[].top] - the pixel offset from the top edge. diff --git a/lib/constructor.js b/lib/constructor.js index eb2c3c4ae..249406889 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -126,6 +126,17 @@ const debuglog = util.debuglog('sharp'); * @param {string} [options.create.noise.type] - type of generated noise, currently only `gaussian` is supported. * @param {number} [options.create.noise.mean] - mean of pixels in generated noise. * @param {number} [options.create.noise.sigma] - standard deviation of pixels in generated noise. + * @param {Object} [options.text] - describes a new text image to be created. + * @param {string} [options.text.text] - text to render as a UTF-8 string. It can contain Pango markup, for example `LeMonde`. + * @param {string} [options.text.font] - font name to render with. + * @param {string} [options.text.fontfile] - absolute filesystem path to a font file that can be used by `font`. + * @param {number} [options.text.width=0] - integral number of pixels to word-wrap at. Lines of text wider than this will be broken at word boundaries. + * @param {number} [options.text.height=0] - integral number of pixels high. When defined, `dpi` will be ignored and the text will automatically fit the pixel resolution defined by `width` and `height`. Will be ignored if `width` is not specified or set to 0. + * @param {string} [options.text.align='left'] - text alignment (`'left'`, `'centre'`, `'center'`, `'right'`). + * @param {boolean} [options.text.justify=false] - set this to true to apply justification to the text. + * @param {number} [options.text.dpi=72] - the resolution (size) at which to render the text. Does not take effect if `height` is specified. + * @param {boolean} [options.text.rgba=false] - set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for pango markup features like `Red!`. + * @param {number} [options.text.spacing=0] - text line height in points. Will use the font line height if none is specified. * @returns {Sharp} * @throws {Error} Invalid parameters */ diff --git a/lib/input.js b/lib/input.js index c90f7a631..7520859a6 100644 --- a/lib/input.js +++ b/lib/input.js @@ -4,6 +4,18 @@ const color = require('color'); const is = require('./is'); const sharp = require('./sharp'); +/** + * Justication alignment + * @member + * @private + */ +const align = { + left: 'low', + center: 'centre', + centre: 'centre', + right: 'high' +}; + /** * Extract input options, if any, from an object. * @private @@ -245,6 +257,81 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { throw new Error('Expected valid width, height and channels to create a new input image'); } } + // Create a new image with text + if (is.defined(inputOptions.text)) { + if (is.object(inputOptions.text) && is.string(inputOptions.text.text)) { + inputDescriptor.textValue = inputOptions.text.text; + if (is.defined(inputOptions.text.height) && is.defined(inputOptions.text.dpi)) { + throw new Error('Expected only one of dpi or height'); + } + if (is.defined(inputOptions.text.font)) { + if (is.string(inputOptions.text.font)) { + inputDescriptor.textFont = inputOptions.text.font; + } else { + throw is.invalidParameterError('text.font', 'string', inputOptions.text.font); + } + } + if (is.defined(inputOptions.text.fontfile)) { + if (is.string(inputOptions.text.fontfile)) { + inputDescriptor.textFontfile = inputOptions.text.fontfile; + } else { + throw is.invalidParameterError('text.fontfile', 'string', inputOptions.text.fontfile); + } + } + if (is.defined(inputOptions.text.width)) { + if (is.number(inputOptions.text.width)) { + inputDescriptor.textWidth = inputOptions.text.width; + } else { + throw is.invalidParameterError('text.textWidth', 'number', inputOptions.text.width); + } + } + if (is.defined(inputOptions.text.height)) { + if (is.number(inputOptions.text.height)) { + inputDescriptor.textHeight = inputOptions.text.height; + } else { + throw is.invalidParameterError('text.height', 'number', inputOptions.text.height); + } + } + if (is.defined(inputOptions.text.align)) { + if (is.string(inputOptions.text.align) && is.string(this.constructor.align[inputOptions.text.align])) { + inputDescriptor.textAlign = this.constructor.align[inputOptions.text.align]; + } else { + throw is.invalidParameterError('text.align', 'valid alignment', inputOptions.text.align); + } + } + if (is.defined(inputOptions.text.justify)) { + if (is.bool(inputOptions.text.justify)) { + inputDescriptor.textJustify = inputOptions.text.justify; + } else { + throw is.invalidParameterError('text.justify', 'boolean', inputOptions.text.justify); + } + } + if (is.defined(inputOptions.text.dpi)) { + if (is.number(inputOptions.text.dpi) && is.inRange(inputOptions.text.dpi, 1, 100000)) { + inputDescriptor.textDpi = inputOptions.text.dpi; + } else { + throw is.invalidParameterError('text.dpi', 'number between 1 and 100000', inputOptions.text.dpi); + } + } + if (is.defined(inputOptions.text.rgba)) { + if (is.bool(inputOptions.text.rgba)) { + inputDescriptor.textRgba = inputOptions.text.rgba; + } else { + throw is.invalidParameterError('text.rgba', 'bool', inputOptions.text.rgba); + } + } + if (is.defined(inputOptions.text.spacing)) { + if (is.number(inputOptions.text.spacing)) { + inputDescriptor.textSpacing = inputOptions.text.spacing; + } else { + throw is.invalidParameterError('text.spacing', 'number', inputOptions.text.spacing); + } + } + delete inputDescriptor.buffer; + } else { + throw new Error('Expected a valid string to create an image with text.'); + } + } } else if (is.defined(inputOptions)) { throw new Error('Invalid input options ' + inputOptions); } @@ -504,4 +591,6 @@ module.exports = function (Sharp) { metadata, stats }); + // Class attributes + Sharp.align = align; }; diff --git a/package.json b/package.json index 1b31fd7ba..a8bf87fe3 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "Chris Banks ", "Ompal Singh ", "Brodan " + "Ankur Parihar ", + "Brahim Ait elhaj " ], "scripts": { "install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile && node-gyp rebuild && node install/dll-copy)", diff --git a/src/common.cc b/src/common.cc index 751e41d73..409bef368 100644 --- a/src/common.cc +++ b/src/common.cc @@ -133,6 +133,42 @@ namespace sharp { descriptor->createBackground = AttrAsVectorOfDouble(input, "createBackground"); } } + // Create new image with text + if (HasAttr(input, "textValue")) { + descriptor->textValue = AttrAsStr(input, "textValue"); + if (HasAttr(input, "textFont")) { + descriptor->textFont = AttrAsStr(input, "textFont"); + } + if (HasAttr(input, "textFontfile")) { + descriptor->textFontfile = AttrAsStr(input, "textFontfile"); + } + if (HasAttr(input, "textFontfile")) { + descriptor->textFontfile = AttrAsStr(input, "textFontfile"); + } + if (HasAttr(input, "textWidth")) { + descriptor->textWidth = AttrAsUint32(input, "textWidth"); + } + if (HasAttr(input, "textHeight")) { + descriptor->textHeight = AttrAsUint32(input, "textHeight"); + } + if (HasAttr(input, "textAlign")) { + descriptor->textAlign = static_cast( + vips_enum_from_nick(nullptr, VIPS_TYPE_ALIGN, + AttrAsStr(input, "textAlign").data())); + } + if (HasAttr(input, "textJustify")) { + descriptor->textJustify = AttrAsBool(input, "textJustify"); + } + if (HasAttr(input, "textDpi")) { + descriptor->textDpi = AttrAsUint32(input, "textDpi"); + } + if (HasAttr(input, "textRgba")) { + descriptor->textRgba = AttrAsBool(input, "textRgba"); + } + if (HasAttr(input, "textSpacing")) { + descriptor->textSpacing = AttrAsUint32(input, "textSpacing"); + } + } // Limit input images to a given number of pixels, where pixels = width * height descriptor->limitInputPixels = static_cast(AttrAsInt64(input, "limitInputPixels")); // Allow switch from random to sequential access @@ -394,6 +430,31 @@ namespace sharp { image.get_image()->Type = image.guess_interpretation(); image = image.cast(VIPS_FORMAT_UCHAR); imageType = ImageType::RAW; + } else if (descriptor->textValue.length() > 0) { + // Create a new image with text + vips::VOption *textOptions = VImage::option() + ->set("align", descriptor->textAlign) + ->set("justify", descriptor->textJustify) + ->set("rgba", descriptor->textRgba) + ->set("spacing", descriptor->textSpacing); + if (descriptor->textWidth > 0) { + textOptions->set("width", descriptor->textWidth); + } + // Ignore dpi if height is set + if (descriptor->textWidth > 0 && descriptor->textHeight > 0) { + textOptions->set("height", descriptor->textHeight); + } else if (descriptor->textDpi > 0) { + textOptions->set("dpi", descriptor->textDpi); + } + if (descriptor->textFont.length() > 0) { + textOptions->set("font", const_cast(descriptor->textFont.data())); + } + if (descriptor->textFontfile.length() > 0) { + textOptions->set("fontfile", const_cast(descriptor->textFontfile.data())); + } + image = VImage::new_memory().text(const_cast(descriptor->textValue.data()), textOptions); + image.get_image()->Type = image.guess_interpretation(); + imageType = ImageType::RAW; } else { // From filesystem imageType = DetermineImageType(descriptor->file.data()); diff --git a/src/common.h b/src/common.h index 2a43597d6..3e29c4404 100644 --- a/src/common.h +++ b/src/common.h @@ -71,6 +71,16 @@ namespace sharp { std::string createNoiseType; double createNoiseMean; double createNoiseSigma; + std::string textValue; + std::string textFont; + std::string textFontfile; + int textWidth; + int textHeight; + VipsAlign textAlign; + bool textJustify; + int textDpi; + bool textRgba; + int textSpacing; InputDescriptor(): buffer(nullptr), @@ -95,7 +105,14 @@ namespace sharp { createHeight(0), createBackground{ 0.0, 0.0, 0.0, 255.0 }, createNoiseMean(0.0), - createNoiseSigma(0.0) {} + createNoiseSigma(0.0), + textWidth(0), + textHeight(0), + textAlign(VIPS_ALIGN_LOW), + textJustify(FALSE), + textDpi(72), + textRgba(FALSE), + textSpacing(0) {} }; // Convenience methods to access the attributes of a Napi::Object diff --git a/test/unit/text.js b/test/unit/text.js new file mode 100644 index 000000000..1f081c5cc --- /dev/null +++ b/test/unit/text.js @@ -0,0 +1,274 @@ +'use strict'; + +const assert = require('assert'); + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +describe('Text to image', () => { + it('text with default values', async () => { + const output = fixtures.path('output.text-default.png'); + const text = sharp({ + text: { + text: 'Hello, world !' + } + }); + const info = await text.png().toFile(output); + assert.strictEqual('png', info.format); + assert.strictEqual(3, info.channels); + assert.strictEqual(false, info.premultiplied); + assert.ok(info.width > 10); + assert.ok(info.height > 8); + const metadata = await sharp(output).metadata(); + assert.strictEqual('uchar', metadata.depth); + assert.strictEqual('srgb', metadata.space); + assert.strictEqual(72, metadata.density); + const stats = await sharp(output).stats(); + assert.strictEqual(0, stats.channels[0].min); + assert.strictEqual(255, stats.channels[0].max); + assert.strictEqual(0, stats.channels[1].min); + assert.strictEqual(255, stats.channels[1].max); + assert.strictEqual(0, stats.channels[2].min); + assert.strictEqual(255, stats.channels[2].max); + }); + + it('text with width and height', function (done) { + const output = fixtures.path('output.text-width-height.png'); + const maxWidth = 500; + const maxHeight = 500; + const text = sharp({ + text: { + text: 'Hello, world!', + width: maxWidth, + height: maxHeight + } + }); + text.toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + assert.strictEqual(3, info.channels); + assert.ok(info.width > 10 && info.width <= maxWidth); + assert.ok(info.height > 10 && info.height <= maxHeight); + assert.ok(Math.abs(info.width - maxWidth) < 50); + done(); + }); + }); + + it('text with dpi', function (done) { + const output = fixtures.path('output.text-dpi.png'); + const dpi = 300; + const text = sharp({ + text: { + text: 'Hello, world!', + dpi: dpi + } + }); + text.toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + sharp(output).metadata(function (err, metadata) { + if (err) throw err; + assert.strictEqual(dpi, metadata.density); + done(); + }); + }); + }); + + it('text with color and pango markup', function (done) { + const output = fixtures.path('output.text-color-pango.png'); + const dpi = 300; + const text = sharp({ + text: { + text: 'redblue', + rgba: true, + dpi: dpi + } + }); + text.toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + assert.strictEqual(4, info.channels); + sharp(output).metadata(function (err, metadata) { + if (err) throw err; + assert.strictEqual(dpi, metadata.density); + assert.strictEqual('uchar', metadata.depth); + assert.strictEqual(true, metadata.hasAlpha); + done(); + }); + }); + }); + + it('text with font', function (done) { + const output = fixtures.path('output.text-with-font.png'); + const text = sharp({ + text: { + text: 'Hello, world!', + font: 'sans 100' + } + }); + text.toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + assert.strictEqual(3, info.channels); + assert.ok(info.width > 30); + assert.ok(info.height > 10); + done(); + }); + }); + + it('text with justify and composite', done => { + const output = fixtures.path('output.text-composite.png'); + const width = 500; + const dpi = 300; + const text = sharp(fixtures.inputJpg) + .resize(width) + .composite([{ + input: { + text: { + text: 'Watermark is cool', + width: 300, + height: 300, + justify: true, + align: 'right', + spacing: 50, + rgba: true + } + }, + gravity: 'northeast' + }, { + input: { + text: { + text: 'cool', + font: 'sans 30', + dpi: dpi, + rgba: true + } + }, + left: 30, + top: 250 + }]); + text.toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + assert.strictEqual(4, info.channels); + assert.strictEqual(width, info.width); + assert.strictEqual(true, info.premultiplied); + sharp(output).metadata(function (err, metadata) { + if (err) throw err; + assert.strictEqual('srgb', metadata.space); + assert.strictEqual('uchar', metadata.depth); + assert.strictEqual(true, metadata.hasAlpha); + done(); + }); + }); + }); + + it('bad text input', function () { + assert.throws(function () { + sharp({ + text: { + } + }); + }); + }); + + it('bad font input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + font: 12 + } + }); + }); + }); + + it('bad fontfile input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + fontfile: true + } + }); + }); + }); + + it('bad width input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + width: 'bad' + } + }); + }); + }); + + it('bad height input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + height: 'bad' + } + }); + }); + }); + + it('bad align input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + align: 'unknown' + } + }); + }); + }); + + it('bad dpi input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + dpi: -10 + } + }); + }); + }); + + it('bad rgba input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + rgba: -10 + } + }); + }); + }); + + it('bad spacing input', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + spacing: 'number expected' + } + }); + }); + }); + + it('only height or dpi not both', function () { + assert.throws(function () { + sharp({ + text: { + text: 'text', + height: 400, + dpi: 100 + } + }); + }); + }); +});