From 9b608fd93a44e02ca705fbabce579a39ae2c5119 Mon Sep 17 00:00:00 2001 From: Fred Cox Date: Thu, 18 Jul 2024 12:44:34 +0100 Subject: [PATCH] Add link tags for pdfs --- CHANGELOG.md | 1 + Readme.md | 16 +++++ examples/pdf-link-rect.js | 20 ++++++ examples/pdf-link.js | 19 ++++++ index.d.ts | 2 + src/CanvasRenderingContext2d.cc | 107 +++++++++++++++++++++++++++++++- src/CanvasRenderingContext2d.h | 4 ++ test/canvas.test.js | 40 +++++++++++- 8 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 examples/pdf-link-rect.js create mode 100644 examples/pdf-link.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e253d1e15..6cef26f56 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..c7a0f4cc0 100644 --- a/Readme.md +++ b/Readme.md @@ -521,6 +521,22 @@ 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() +``` + +Or with a defined rectangle: + +```js +ctx.beginTag({name: 'Link', uri: 'https://google.com', rect: { x: 50, y: 80, width: 100, height: 20 }}) +ctx.closeTag() +``` + See also: * [Image#dataMode](#imagedatamode) for embedding JPEGs in PDFs diff --git a/examples/pdf-link-rect.js b/examples/pdf-link-rect.js new file mode 100644 index 000000000..7cb661c3c --- /dev/null +++ b/examples/pdf-link-rect.js @@ -0,0 +1,20 @@ +const fs = require('fs') +const Canvas = require('..') + +const canvas = Canvas.createCanvas(400, 200, 'pdf') +const ctx = canvas.getContext('2d') + +let x = 50 +let y = 80 + +ctx.beginTag({ name: 'Link', uri: 'https://google.com', rect: { x: 40, y: 70, width: 100, height: 50 } }) +ctx.closeTag() + +ctx.font = '22px Helvetica' +ctx.fillText('node-canvas pdf', x, y) + +fs.writeFile('out.pdf', canvas.toBuffer(), function (err) { + if (err) throw err + + console.log('created out.pdf') +}) 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 97fe03962..eff43aaf4 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, rect?: {x: number, y: number, width: number, height: number}}): 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 d0966e299..590e80497 100644 --- a/src/CanvasRenderingContext2d.cc +++ b/src/CanvasRenderingContext2d.cc @@ -19,6 +19,7 @@ #include #include "Util.h" #include +#include /* * Rectangle arg assertions. @@ -134,6 +135,10 @@ Context2d::Initialize(Napi::Env& env, Napi::Object& exports) { InstanceMethod<&Context2d::CreatePattern>("createPattern", napi_default_method), InstanceMethod<&Context2d::CreateLinearGradient>("createLinearGradient", napi_default_method), InstanceMethod<&Context2d::CreateRadialGradient>("createRadialGradient", napi_default_method), + #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", napi_default_jsproperty), InstanceAccessor<&Context2d::GetPatternQuality, &Context2d::SetPatternQuality>("patternQuality", napi_default_jsproperty), InstanceAccessor<&Context2d::GetImageSmoothingEnabled, &Context2d::SetImageSmoothingEnabled>("imageSmoothingEnabled", napi_default_jsproperty), @@ -418,7 +423,7 @@ Context2d::fill(bool preserve) { width = cairo_image_surface_get_width(patternSurface); height = y2 - y1; } - + cairo_new_path(_context); cairo_rectangle(_context, 0, 0, width, height); cairo_clip(_context); @@ -3352,3 +3357,103 @@ 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) || nameValue.IsUndefined()) { + 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) || uriValue.IsUndefined()) { + 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; + } + + std::string rectAttr; + Napi::Object rect; + if (config.Get("rect").UnwrapTo(&rect) && !rect.IsUndefined()) { + if (!rect.IsObject()) { + Napi::TypeError::New(env, "rect must be an object").ThrowAsJavaScriptException(); + return; + } + + Napi::Number x, y, width, height; + if (!rect.Get("x").UnwrapTo(&x) || x.IsUndefined() || + !rect.Get("y").UnwrapTo(&y) || y.IsUndefined() || + !rect.Get("width").UnwrapTo(&width) || width.IsUndefined() || + !rect.Get("height").UnwrapTo(&height) || height.IsUndefined()) { + Napi::TypeError::New(env, "rect must contain x, y, width, height").ThrowAsJavaScriptException(); + return; + } + + float xF = x.FloatValue(), yF = y.FloatValue(), widthF = width.FloatValue(), heightF = height.FloatValue(); + if (xF <= 0 || yF <= 0 || widthF <= 0 || heightF <= 0) { + Napi::TypeError::New(env, "rect values must be positive").ThrowAsJavaScriptException(); + return; + } + + rectAttr = " rect=[" + std::to_string(xF) + " " + std::to_string(yF) + " " + std::to_string(widthF) + " " + std::to_string(heightF) + "]"; + } + + replaceAll(uri, "'", "\\'"); + std::string attrs = "uri='" + uri + "'" + rectAttr; + + 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 1a75ac031..c29e14cf5 100644 --- a/test/canvas.test.js +++ b/test/canvas.test.js @@ -17,7 +17,8 @@ const { parseFont, registerFont, Canvas, - deregisterAllFonts + deregisterAllFonts, + cairoVersion } = require('../') function assertApprox(actual, expected, tol) { @@ -828,6 +829,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 () { @@ -2073,4 +2079,36 @@ describe('Canvas', function () { }) } }) + + describe('Context2d#beingTag()/endTag()', function () { + before(function () { + const canvas = createCanvas(20, 20, 'pdf') + const ctx = canvas.getContext('2d') + if (!('beginTag' in ctx)) { + this.skip() + } + }) + + 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' }) }) + }) + }) })