Skip to content

Commit f947a10

Browse files
mcfedrchearon
andcommitted
Add link tags for pdfs
Co-Authored-By: Caleb Hearon <[email protected]>
1 parent 728e76c commit f947a10

File tree

7 files changed

+155
-2
lines changed

7 files changed

+155
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ project adheres to [Semantic Versioning](http://semver.org/).
1212
* `ctx.font` has a new C++ parser and is 2x-400x faster. Please file an issue if you experience different results, as caching has been removed.
1313

1414
### Added
15+
* Support for accessibility and links in PDFs
16+
1517
### Fixed
1618

1719
3.0.1

Readme.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,26 @@ ctx.addPage(400, 800)
515515
ctx.fillText('Hello World 2', 50, 80)
516516
```
517517

518+
It is possible to add hyperlinks using `.beginTag()` and `.endTag()`:
519+
520+
```js
521+
ctx.beginTag('Link', "uri='https://google.com'")
522+
ctx.font = '22px Helvetica'
523+
ctx.fillText('Hello World', 50, 80)
524+
ctx.endTag('Link')
525+
```
526+
527+
Or with a defined rectangle:
528+
529+
```js
530+
ctx.beginTag('Link', "uri='https://google.com' rect=[50 80 100 20]")
531+
ctx.endTag('Link')
532+
```
533+
534+
Note that the syntax is unique to Cairo. See [cairo_tag_begin](https://www.cairographics.org/manual/cairo-Tags-and-Links.html#cairo-tag-begin) for the full documentation.
535+
536+
You can create areas on the canvas using the "cairo.dest" tag, and then link to them using the "Link" tag with the `dest=` attribute. You can also define PDF structure for accessibility by using tag names like "P", "H1", and "TABLE". The standard tags are defined in §14.8.4 of the [PDF 1.7](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf) specification.
537+
518538
See also:
519539

520540
* [Image#dataMode](#imagedatamode) for embedding JPEGs in PDFs

examples/pdf-link.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
const Canvas = require('..')
4+
5+
const canvas = Canvas.createCanvas(400, 300, 'pdf')
6+
const ctx = canvas.getContext('2d')
7+
8+
ctx.beginTag('Link', 'uri=\'https://google.com\'')
9+
ctx.font = '22px Helvetica'
10+
ctx.fillText('Text link to Google', 110, 50)
11+
ctx.endTag('Link')
12+
13+
ctx.fillText('Rect link to node-canvas below!', 40, 180)
14+
15+
ctx.beginTag('Link', 'uri=\'https://github.com/Automattic/node-canvas\' rect=[0 200 400 100]')
16+
ctx.endTag('Link')
17+
18+
fs.writeFile(path.join(__dirname, 'pdf-link.pdf'), canvas.toBuffer(), function (err) {
19+
if (err) throw err
20+
})

index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ export class CanvasRenderingContext2D {
232232
createPattern(image: Canvas|Image, repetition: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | '' | null): CanvasPattern
233233
createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient;
234234
createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient;
235+
beginTag(tagName: string, attributes?: string): void;
236+
endTag(tagName: string): void;
235237
/**
236238
* _Non-standard_. Defaults to 'good'. Affects pattern (gradient, image,
237239
* etc.) rendering quality.

src/CanvasRenderingContext2d.cc

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ Context2d::Initialize(Napi::Env& env, Napi::Object& exports) {
135135
InstanceMethod<&Context2d::CreatePattern>("createPattern", napi_default_method),
136136
InstanceMethod<&Context2d::CreateLinearGradient>("createLinearGradient", napi_default_method),
137137
InstanceMethod<&Context2d::CreateRadialGradient>("createRadialGradient", napi_default_method),
138+
#if CAIRO_VERSION >= CAIRO_VERSION_ENCODE(1, 16, 0)
139+
InstanceMethod<&Context2d::BeginTag>("beginTag", napi_default_method),
140+
InstanceMethod<&Context2d::EndTag>("endTag", napi_default_method),
141+
#endif
138142
InstanceAccessor<&Context2d::GetFormat>("pixelFormat", napi_default_jsproperty),
139143
InstanceAccessor<&Context2d::GetPatternQuality, &Context2d::SetPatternQuality>("patternQuality", napi_default_jsproperty),
140144
InstanceAccessor<&Context2d::GetImageSmoothingEnabled, &Context2d::SetImageSmoothingEnabled>("imageSmoothingEnabled", napi_default_jsproperty),
@@ -419,7 +423,7 @@ Context2d::fill(bool preserve) {
419423
width = cairo_image_surface_get_width(patternSurface);
420424
height = y2 - y1;
421425
}
422-
426+
423427
cairo_new_path(_context);
424428
cairo_rectangle(_context, 0, 0, width, height);
425429
cairo_clip(_context);
@@ -3348,3 +3352,66 @@ Context2d::Ellipse(const Napi::CallbackInfo& info) {
33483352
}
33493353
cairo_set_matrix(ctx, &save_matrix);
33503354
}
3355+
3356+
#if CAIRO_VERSION >= CAIRO_VERSION_ENCODE(1, 16, 0)
3357+
3358+
3359+
bool
3360+
containsOnlyASCII(const std::string& str) {
3361+
for (auto c: str) {
3362+
if (static_cast<unsigned char>(c) > 127) {
3363+
return false;
3364+
}
3365+
}
3366+
return true;
3367+
}
3368+
3369+
void
3370+
Context2d::BeginTag(const Napi::CallbackInfo& info) {
3371+
std::string tagName = "";
3372+
std::string attributes = "";
3373+
3374+
if (info.Length() == 0) {
3375+
Napi::TypeError::New(env, "Tag name is required").ThrowAsJavaScriptException();
3376+
return;
3377+
} else {
3378+
if (!info[0].IsString()) {
3379+
Napi::TypeError::New(env, "Tag name must be a string.").ThrowAsJavaScriptException();
3380+
return;
3381+
} else {
3382+
tagName = info[0].As<Napi::String>().Utf8Value();
3383+
}
3384+
3385+
if (info.Length() > 1) {
3386+
if (!info[1].IsString()) {
3387+
Napi::TypeError::New(env, "Attributes must be a string matching Cairo's attribute format").ThrowAsJavaScriptException();
3388+
return;
3389+
} else {
3390+
attributes = info[1].As<Napi::String>().Utf8Value();
3391+
}
3392+
}
3393+
}
3394+
3395+
cairo_t *ctx = context();
3396+
cairo_tag_begin(ctx, tagName.c_str(), attributes.c_str());
3397+
}
3398+
3399+
void
3400+
Context2d::EndTag(const Napi::CallbackInfo& info) {
3401+
if (info.Length() == 0) {
3402+
Napi::TypeError::New(env, "Tag name is required").ThrowAsJavaScriptException();
3403+
return;
3404+
}
3405+
3406+
if (!info[0].IsString()) {
3407+
Napi::TypeError::New(env, "Tag name must be a string.").ThrowAsJavaScriptException();
3408+
return;
3409+
}
3410+
3411+
std::string tagName = info[0].As<Napi::String>().Utf8Value();
3412+
3413+
cairo_t *ctx = context();
3414+
cairo_tag_end(ctx, tagName.c_str());
3415+
}
3416+
3417+
#endif

src/CanvasRenderingContext2d.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,10 @@ class Context2d : public Napi::ObjectWrap<Context2d> {
178178
void SetFont(const Napi::CallbackInfo& info, const Napi::Value& value);
179179
void SetTextBaseline(const Napi::CallbackInfo& info, const Napi::Value& value);
180180
void SetTextAlign(const Napi::CallbackInfo& info, const Napi::Value& value);
181+
#if CAIRO_VERSION >= CAIRO_VERSION_ENCODE(1, 16, 0)
182+
void BeginTag(const Napi::CallbackInfo& info);
183+
void EndTag(const Napi::CallbackInfo& info);
184+
#endif
181185
inline void setContext(cairo_t *ctx) { _context = ctx; }
182186
inline cairo_t *context(){ return _context; }
183187
inline Canvas *canvas(){ return _canvas; }

test/canvas.test.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const {
1616
loadImage,
1717
registerFont,
1818
Canvas,
19-
deregisterAllFonts
19+
deregisterAllFonts,
20+
cairoVersion
2021
} = require('../')
2122

2223
function assertApprox(actual, expected, tol) {
@@ -755,6 +756,11 @@ describe('Canvas', function () {
755756
assertPixel(0xffff0000, 5, 0, 'first red pixel')
756757
})
757758
})
759+
760+
it('Canvas#toBuffer("application/pdf")', function () {
761+
const buf = createCanvas(200, 200, 'pdf').toBuffer('application/pdf')
762+
assert.equal('PDF', buf.slice(1, 4).toString())
763+
})
758764
})
759765

760766
describe('#toDataURL()', function () {
@@ -2000,4 +2006,36 @@ describe('Canvas', function () {
20002006
})
20012007
}
20022008
})
2009+
2010+
describe('Context2d#beingTag()/endTag()', function () {
2011+
before(function () {
2012+
const canvas = createCanvas(20, 20, 'pdf')
2013+
const ctx = canvas.getContext('2d')
2014+
if (!('beginTag' in ctx)) {
2015+
this.skip()
2016+
}
2017+
})
2018+
2019+
it('generates a pdf', function () {
2020+
const canvas = createCanvas(20, 20, 'pdf')
2021+
const ctx = canvas.getContext('2d')
2022+
ctx.beginTag('Link', "uri='http://example.com'")
2023+
ctx.strokeText('hello', 0, 0)
2024+
ctx.endTag('Link')
2025+
const buf = canvas.toBuffer('application/pdf')
2026+
assert.equal('PDF', buf.slice(1, 4).toString())
2027+
})
2028+
2029+
it('requires tag argument', function () {
2030+
const canvas = createCanvas(20, 20, 'pdf')
2031+
const ctx = canvas.getContext('2d')
2032+
assert.throws(() => { ctx.beginTag() })
2033+
})
2034+
2035+
it('requires attributes to be a string', function () {
2036+
const canvas = createCanvas(20, 20, 'pdf')
2037+
const ctx = canvas.getContext('2d')
2038+
assert.throws(() => { ctx.beginTag('Link', {}) })
2039+
})
2040+
})
20032041
})

0 commit comments

Comments
 (0)