Skip to content

Commit

Permalink
Add link tags for pdfs
Browse files Browse the repository at this point in the history
  • Loading branch information
mcfedr committed Oct 16, 2024
1 parent a2e10e6 commit 9b608fd
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](http://semver.org/).
==================
### Changed
### Added
* Support for links in PDFs
### Fixed


Expand Down
16 changes: 16 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions examples/pdf-link-rect.js
Original file line number Diff line number Diff line change
@@ -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')
})
19 changes: 19 additions & 0 deletions examples/pdf-link.js
Original file line number Diff line number Diff line change
@@ -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')
})
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
107 changes: 106 additions & 1 deletion src/CanvasRenderingContext2d.cc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include <string>
#include "Util.h"
#include <vector>
#include <iostream>

/*
* Rectangle arg assertions.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<unsigned char>(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::Object>();

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
4 changes: 4 additions & 0 deletions src/CanvasRenderingContext2d.h
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ class Context2d : public Napi::ObjectWrap<Context2d> {
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; }
Expand Down
40 changes: 39 additions & 1 deletion test/canvas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const {
parseFont,
registerFont,
Canvas,
deregisterAllFonts
deregisterAllFonts,
cairoVersion
} = require('../')

function assertApprox(actual, expected, tol) {
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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' }) })
})
})
})

0 comments on commit 9b608fd

Please sign in to comment.