diff --git a/CHANGELOG.md b/CHANGELOG.md index 349e1c6f7..7be4f04c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](http://semver.org/). ================== ### Changed ### Added +* Support for links in PDFs ### Fixed diff --git a/Readme.md b/Readme.md index 7e7b88ba1..82619250d 100644 --- a/Readme.md +++ b/Readme.md @@ -521,6 +521,15 @@ ctx.addPage(400, 800) ctx.fillText('Hello World 2', 50, 80) ``` +It is possible to add hyperlinks use `.beginTag()` and `.closeTag()`: + +```js +ctx.beginTag({name: 'Link', uri: 'https://google.com'}) +ctx.font = '22px Helvetica' +ctx.fillText('Hello World', 50, 80) +ctx.closeTag() +``` + See also: * [Image#dataMode](#imagedatamode) for embedding JPEGs in PDFs diff --git a/examples/pdf-link.js b/examples/pdf-link.js new file mode 100644 index 000000000..57863cdee --- /dev/null +++ b/examples/pdf-link.js @@ -0,0 +1,19 @@ +const fs = require('fs') +const Canvas = require('..') + +const canvas = Canvas.createCanvas(400, 200, 'pdf') +const ctx = canvas.getContext('2d') + +let y = 80 +let x = 50 + +ctx.beginTag({ name: 'Link', uri: 'https://google.com' }) +ctx.font = '22px Helvetica' +ctx.fillText('node-canvas pdf', x, y) +ctx.closeTag() + +fs.writeFile('out.pdf', canvas.toBuffer(), function (err) { + if (err) throw err + + console.log('created out.pdf') +}) diff --git a/index.d.ts b/index.d.ts index 49636f396..3c60c993c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -232,6 +232,8 @@ export class CanvasRenderingContext2D { createPattern(image: Canvas|Image, repetition: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | '' | null): CanvasPattern createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient; createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient; + beginTag(config: {name: 'Link', uri: string}): void; + closeTag(): void; /** * _Non-standard_. Defaults to 'good'. Affects pattern (gradient, image, * etc.) rendering quality. diff --git a/src/CanvasRenderingContext2d.cc b/src/CanvasRenderingContext2d.cc index 56e68d899..402638c8c 100644 --- a/src/CanvasRenderingContext2d.cc +++ b/src/CanvasRenderingContext2d.cc @@ -134,6 +134,10 @@ Context2d::Initialize(Napi::Env& env, Napi::Object& exports) { InstanceMethod<&Context2d::CreatePattern>("createPattern"), InstanceMethod<&Context2d::CreateLinearGradient>("createLinearGradient"), InstanceMethod<&Context2d::CreateRadialGradient>("createRadialGradient"), + #if CAIRO_VERSION >= CAIRO_VERSION_ENCODE(1, 16, 0) + InstanceMethod<&Context2d::BeginTag>("beginTag", napi_default_method), + InstanceMethod<&Context2d::CloseTag>("closeTag", napi_default_method), + #endif InstanceAccessor<&Context2d::GetFormat>("pixelFormat"), InstanceAccessor<&Context2d::GetPatternQuality, &Context2d::SetPatternQuality>("patternQuality"), InstanceAccessor<&Context2d::GetImageSmoothingEnabled, &Context2d::SetImageSmoothingEnabled>("imageSmoothingEnabled"), @@ -3354,3 +3358,77 @@ Context2d::Ellipse(const Napi::CallbackInfo& info) { } cairo_set_matrix(ctx, &save_matrix); } + +#if CAIRO_VERSION >= CAIRO_VERSION_ENCODE(1, 16, 0) + +/* + * Open and close a link tag + */ + +void +replaceAll( std::string &s, const std::string &search, const std::string &replace ) { + for( size_t pos = 0; ; pos += replace.length() ) { + // Locate the substring to replace + pos = s.find( search, pos ); + if( pos == std::string::npos ) break; + // Replace by erasing and inserting + s.erase( pos, search.length() ); + s.insert( pos, replace ); + } +} + +bool +containsOnlyASCII(const std::string& str) { + for (auto c: str) { + if (static_cast(c) > 127) { + return false; + } + } + return true; +} + +void +Context2d::BeginTag(const Napi::CallbackInfo& info) { + if (info.Length() < 1 || !info[0].IsObject()) { + Napi::TypeError::New(env, "config must be an object").ThrowAsJavaScriptException(); + return; + } + + Napi::Object config = info[0].As(); + + Napi::String nameValue; + if (!config.Get("name").UnwrapTo(&nameValue)) { + Napi::TypeError::New(env, "config must have a name key").ThrowAsJavaScriptException(); + return; + } + std::string name = nameValue.Utf8Value(); + if (name != CAIRO_TAG_LINK) { + Napi::TypeError::New(env, "name must be 'Link'").ThrowAsJavaScriptException(); + return; + } + + Napi::String uriValue; + if (!config.Get("uri").UnwrapTo(&uriValue)) { + Napi::TypeError::New(env, "config must have a uri key").ThrowAsJavaScriptException(); + return; + } + std::string uri = uriValue.Utf8Value(); + if (!containsOnlyASCII(uri)) { + Napi::TypeError::New(env, "uri must be ascii only").ThrowAsJavaScriptException(); + return; + } + + replaceAll(uri, "'", "\\'"); + std::string attrs = "uri='" + uri + "'"; + + cairo_t *ctx = context(); + cairo_tag_begin(ctx, CAIRO_TAG_LINK, attrs.c_str()); +} + +void +Context2d::CloseTag(const Napi::CallbackInfo& info) { + cairo_t *ctx = context(); + cairo_tag_end(ctx, CAIRO_TAG_LINK); +} + +#endif diff --git a/src/CanvasRenderingContext2d.h b/src/CanvasRenderingContext2d.h index 745106e2d..ccb661b34 100644 --- a/src/CanvasRenderingContext2d.h +++ b/src/CanvasRenderingContext2d.h @@ -178,6 +178,10 @@ class Context2d : public Napi::ObjectWrap { void SetFont(const Napi::CallbackInfo& info, const Napi::Value& value); void SetTextBaseline(const Napi::CallbackInfo& info, const Napi::Value& value); void SetTextAlign(const Napi::CallbackInfo& info, const Napi::Value& value); + #if CAIRO_VERSION >= CAIRO_VERSION_ENCODE(1, 16, 0) + void BeginTag(const Napi::CallbackInfo& info); + void CloseTag(const Napi::CallbackInfo& info); + #endif inline void setContext(cairo_t *ctx) { _context = ctx; } inline cairo_t *context(){ return _context; } inline Canvas *canvas(){ return _canvas; } diff --git a/test/canvas.test.js b/test/canvas.test.js index d0feff0d1..a3e7844b7 100644 --- a/test/canvas.test.js +++ b/test/canvas.test.js @@ -823,6 +823,11 @@ describe('Canvas', function () { assertPixel(0xffff0000, 5, 0, 'first red pixel') }) }) + + it('Canvas#toBuffer("application/pdf")', function () { + const buf = createCanvas(200, 200, 'pdf').toBuffer('application/pdf') + assert.equal('PDF', buf.slice(1, 4).toString()) + }) }) describe('#toDataURL()', function () { @@ -2068,4 +2073,28 @@ describe('Canvas', function () { }) } }) + + describe('Context2d#beingTag()/endTag()', function () { + it ("generates a pdf", function () { + const canvas = createCanvas(20, 20, 'pdf') + const ctx = canvas.getContext('2d') + ctx.beginTag({ name: 'Link', uri: 'tes\'t' }) + ctx.strokeText('hello', 0, 0) + ctx.closeTag() + const buf = canvas.toBuffer('application/pdf') + assert.equal('PDF', buf.slice(1, 4).toString()) + }) + + it("must be a link", function () { + const canvas = createCanvas(20, 20, 'pdf') + const ctx = canvas.getContext('2d') + assert.throws(() => { ctx.beginTag({ name: 'other', uri: 'test' }) }) + }) + + it("must be a ascii", function () { + const canvas = createCanvas(20, 20, 'pdf') + const ctx = canvas.getContext('2d') + assert.throws(() => { ctx.beginTag({ name: 'Link', uri: 'має бути ascii' }) }) + }) + }) })