From da33bbed88946188385af6dc10368410ffede365 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 Co-Authored-By: Caleb Hearon --- .gitignore | 1 + CHANGELOG.md | 2 ++ Readme.md | 20 ++++++++++++ examples/pdf-link.js | 20 ++++++++++++ index.d.ts | 2 ++ src/CanvasRenderingContext2d.cc | 56 ++++++++++++++++++++++++++++++++- src/CanvasRenderingContext2d.h | 4 +++ test/canvas.test.js | 37 ++++++++++++++++++++++ 8 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 examples/pdf-link.js diff --git a/.gitignore b/.gitignore index ff66b1103..4fd0b5eda 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build test/images/*.png examples/*.png examples/*.jpg +examples/*.pdf testing out.png out.pdf diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9e0ca08..11d8a039d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ project adheres to [Semantic Versioning](http://semver.org/). * `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. ### Added +* Support for accessibility and links in PDFs + ### Fixed 3.0.1 diff --git a/Readme.md b/Readme.md index 73c65d369..4cb17701c 100644 --- a/Readme.md +++ b/Readme.md @@ -515,6 +515,26 @@ ctx.addPage(400, 800) ctx.fillText('Hello World 2', 50, 80) ``` +It is possible to add hyperlinks using `.beginTag()` and `.endTag()`: + +```js +ctx.beginTag('Link', "uri='https://google.com'") +ctx.font = '22px Helvetica' +ctx.fillText('Hello World', 50, 80) +ctx.endTag('Link') +``` + +Or with a defined rectangle: + +```js +ctx.beginTag('Link', "uri='https://google.com' rect=[50 80 100 20]") +ctx.endTag('Link') +``` + +Note that the syntax for attributes 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. + +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. + 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..f6e40291b --- /dev/null +++ b/examples/pdf-link.js @@ -0,0 +1,20 @@ +const fs = require('fs') +const path = require('path') +const Canvas = require('..') + +const canvas = Canvas.createCanvas(400, 300, 'pdf') +const ctx = canvas.getContext('2d') + +ctx.beginTag('Link', 'uri=\'https://google.com\'') +ctx.font = '22px Helvetica' +ctx.fillText('Text link to Google', 110, 50) +ctx.endTag('Link') + +ctx.fillText('Rect link to node-canvas below!', 40, 180) + +ctx.beginTag('Link', 'uri=\'https://github.com/Automattic/node-canvas\' rect=[0 200 400 100]') +ctx.endTag('Link') + +fs.writeFile(path.join(__dirname, 'pdf-link.pdf'), canvas.toBuffer(), function (err) { + if (err) throw err +}) diff --git a/index.d.ts b/index.d.ts index ce21cef26..6458bc132 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(tagName: string, attributes?: string): void; + endTag(tagName: string): void; /** * _Non-standard_. Defaults to 'good'. Affects pattern (gradient, image, * etc.) rendering quality. diff --git a/src/CanvasRenderingContext2d.cc b/src/CanvasRenderingContext2d.cc index 1597d089a..f8f217ad9 100644 --- a/src/CanvasRenderingContext2d.cc +++ b/src/CanvasRenderingContext2d.cc @@ -135,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::EndTag>("endTag", 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), @@ -419,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); @@ -3348,3 +3352,53 @@ Context2d::Ellipse(const Napi::CallbackInfo& info) { } cairo_set_matrix(ctx, &save_matrix); } + +#if CAIRO_VERSION >= CAIRO_VERSION_ENCODE(1, 16, 0) + +void +Context2d::BeginTag(const Napi::CallbackInfo& info) { + std::string tagName = ""; + std::string attributes = ""; + + if (info.Length() == 0) { + Napi::TypeError::New(env, "Tag name is required").ThrowAsJavaScriptException(); + return; + } else { + if (!info[0].IsString()) { + Napi::TypeError::New(env, "Tag name must be a string.").ThrowAsJavaScriptException(); + return; + } else { + tagName = info[0].As().Utf8Value(); + } + + if (info.Length() > 1) { + if (!info[1].IsString()) { + Napi::TypeError::New(env, "Attributes must be a string matching Cairo's attribute format").ThrowAsJavaScriptException(); + return; + } else { + attributes = info[1].As().Utf8Value(); + } + } + } + + cairo_tag_begin(_context, tagName.c_str(), attributes.c_str()); +} + +void +Context2d::EndTag(const Napi::CallbackInfo& info) { + if (info.Length() == 0) { + Napi::TypeError::New(env, "Tag name is required").ThrowAsJavaScriptException(); + return; + } + + if (!info[0].IsString()) { + Napi::TypeError::New(env, "Tag name must be a string.").ThrowAsJavaScriptException(); + return; + } + + std::string tagName = info[0].As().Utf8Value(); + + cairo_tag_end(_context, tagName.c_str()); +} + +#endif diff --git a/src/CanvasRenderingContext2d.h b/src/CanvasRenderingContext2d.h index 745106e2d..a78788451 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 EndTag(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 75f15ed5a..ecadbc7ec 100644 --- a/test/canvas.test.js +++ b/test/canvas.test.js @@ -755,6 +755,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 () { @@ -2000,4 +2005,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('Link', "uri='http://example.com'") + ctx.strokeText('hello', 0, 0) + ctx.endTag('Link') + const buf = canvas.toBuffer('application/pdf') + assert.equal('PDF', buf.slice(1, 4).toString()) + }) + + it('requires tag argument', function () { + const canvas = createCanvas(20, 20, 'pdf') + const ctx = canvas.getContext('2d') + assert.throws(() => { ctx.beginTag() }) + }) + + it('requires attributes to be a string', function () { + const canvas = createCanvas(20, 20, 'pdf') + const ctx = canvas.getContext('2d') + assert.throws(() => { ctx.beginTag('Link', {}) }) + }) + }) })