From 1e67b1bb62eab6f1672a91514e41553fed13dc1a Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 19 Oct 2026 10:30:41 +0200 Subject: [PATCH] Draw glyphs a layout placed; radial shadings; straight colour under a soft mask A layout engine that prints what it laid out needs to put its own glyphs where it measured them, in the fonts it measured with. pdfkit could only re-derive positions from a string (Text, TextShaped) and load a font from bytes the engine no longer has. - Page.ShowGlyphs(x, y, []Glyph): glyph ids at caller-computed positions, each with the text it stands for (the /ToUnicode entry, ISO 32000-1 9.10.3). One TJ array per baseline (9.4.3), a correction only where a position departs from /W by a thousandth of an em, from absolute positions so the pen never drifts. Refuses, before writing, a glyph id the font lacks and a non-finite position. - NewFont(*opentype.Font): embed a font another component already parsed. - Shading.Radial, R0, R1: type 3 radial shadings (8.7.4.5.4); radii must be finite, >= 0, not both 0. - DrawImage: the colour samples under an /SMask are now written straight. image.Image's RGBA and *image.RGBA's Pix are premultiplied; with no /Matte the samples "shall not be preblended" (11.6.5.3, Table 145), so a half-transparent edge came out darkened towards black. Coverage stays at 100 %. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++- glyphs.go | 134 +++++++++++++++++++++++++ glyphs_test.go | 258 +++++++++++++++++++++++++++++++++++++++++++++++++ image.go | 31 +++++- shading.go | 32 +++++- 5 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 glyphs.go create mode 100644 glyphs_test.go diff --git a/README.md b/README.md index 0b00ff5..ce6e5ad 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,16 @@ Go-idiomatic rather than a gem port. **CFF charstring subsetting** (only the used glyphs' charstrings are embedded). Char/word spacing, leading, render modes, a simple wrapping helper, and an optional **shaped-text** API (GSUB/GPOS via go-opentype) for Arabic/Indic/CJK. + A layout engine that places its own glyphs draws them with + **`Page.ShowGlyphs`** — glyph ids at the positions it computed, each with the + text it stands for — and hands over the fonts it already parsed with + **`NewFont(*opentype.Font)`**. +- **Gradients** — axial (type 2) and **radial (type 3)** shadings with any + number of stops, painted through the clip (`Page.Shade`). - **Images** — JPEG embedded directly (DCTDecode); PNG and any `image.Image` - rasterised as XObjects (FlateDecode) with an `/SMask` for alpha. + rasterised as XObjects (FlateDecode) with an `/SMask` for alpha, the colour + samples written straight (not premultiplied), as a soft mask without + `/Matte` requires. **Pixel-identical bitmaps are shared**: each image is content-addressed by its *uncompressed* samples, so a repeat is embedded — and compressed — once per document and every placement, on any page, references the one XObject. @@ -93,6 +101,14 @@ marks), none for plain text — and unchanged font/colour state is not rewritten, so a page of prose is a few tens of KB compressed, not hundreds. The default `Text` path stays a simple left-to-right cmap mapping. +Text a layout engine has already shaped and placed goes through +`p.ShowGlyphs(x, y, glyphs)`: each `pdfkit.Glyph` is a glyph id, its origin's +offset from the run origin and the text it stands for (the `/ToUnicode` +entry). The same `TJ` encoding applies — a correction only where a position +departs from the font's own advance by a thousandth of an em or more, computed +from absolute positions so that the pen never drifts. go-webengine's +`pdfcanvas` draws a laid-out page this way. + ### Printing a widget tree ```go diff --git a/glyphs.go b/glyphs.go new file mode 100644 index 0000000..4518299 --- /dev/null +++ b/glyphs.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package pdfkit + +import ( + "errors" + "fmt" + "math" + "strings" + + "github.com/go-opentype/opentype" +) + +// NewFont wraps a font another component has already parsed, so that a +// program which laid its text out with an *opentype.Font — a layout engine +// measuring with the very faces it will print — embeds that same font +// without keeping its bytes or parsing it a second time. +// +// The font must carry a 'cmap', as for LoadFont. A Font made this way has no +// whole-font fallback: embedding always goes through go-opentype's subsetter, +// which can only fail on a glyph id the font does not have, and ShowGlyphs +// refuses those up front. +func NewFont(ot *opentype.Font) (*Font, error) { + if ot == nil { + return nil, errors.New("pdfkit: NewFont: nil font") + } + if _, ok := ot.Table("cmap"); !ok { + return nil, fmt.Errorf("pdfkit: NewFont: no cmap table, the font maps no characters") + } + name := "" + if nameTable, ok := ot.Table("name"); ok { + name = readPSName(nameTable) + } + if name == "" { + name = "PDFKitFont" + } + return &Font{ot: ot, face: ot.NewFace(ot.UnitsPerEm()), baseName: name, isCFF: fontIsCFF(ot)}, nil +} + +// Glyph is one glyph of a run whose layout the caller has already done: the +// glyph to draw, where its origin goes and the text it stands for. +// +// X and Y are the glyph origin's offset from the run origin, in points, Y +// upwards as everywhere in the page's user space. Text is what copying the +// glyph out of the PDF should give (the /ToUnicode mapping, ISO 32000-1 +// §9.10.3): one character for most glyphs, several for a ligature, empty for +// a glyph that continues a cluster an earlier glyph already spoke for. +type Glyph struct { + ID opentype.GlyphIndex + X, Y float64 + Text string +} + +// ShowGlyphs draws glyphs, already chosen and positioned by the caller, with +// the run origin at (x, y) in the current font and size. +// +// It is the entry point for a layout engine that shapes and measures text +// itself: the PDF then shows the glyphs at exactly the positions the layout +// computed instead of re-deriving them from the font, which is what keeps a +// justified line, a letter-spaced heading or a tab stop where the layout put +// it. Each baseline segment is one TJ array (ISO 32000-1 §9.4.3): the viewer +// advances by each glyph's /W width, and a number is written only where the +// caller's position differs from that by at least a thousandth of an em, so +// plain unkerned text carries no numbers at all and the pen never drifts. A +// change of Y starts a new segment. +// +// It reports errNoFont without a font, and an error — before writing +// anything — for a glyph id the font does not have or a position that is not +// a finite number. +func (p *Page) ShowGlyphs(x, y float64, glyphs []Glyph) error { + f := p.curFont + if f == nil { + return errNoFont + } + if len(glyphs) == 0 { + return nil + } + n := opentype.GlyphIndex(f.ot.NumGlyphs()) + for i, g := range glyphs { + if g.ID >= n { + return fmt.Errorf("pdfkit: ShowGlyphs: glyph %d is id %d, the font has %d glyphs", i, g.ID, n) + } + if !finite(g.X) || !finite(g.Y) { + return fmt.Errorf("pdfkit: ShowGlyphs: glyph %d is at a non-finite position", i) + } + } + if !finite(x) || !finite(y) { + return errors.New("pdfkit: ShowGlyphs: non-finite run origin") + } + if p.fontSize == 0 { + return errors.New("pdfkit: ShowGlyphs: font size is zero") + } + use := p.doc.use[f] + toMil := 1000 / p.fontSize // points -> thousandths of an em + + p.op("", "BT") + p.emitTextState() + var arr strings.Builder + flush := func() { + if arr.Len() > 0 { + p.op("["+arr.String()+"]", "TJ") + arr.Reset() + } + } + open := false + segX, segY := 0.0, 0.0 // the current segment's origin, relative to the run + viewerPen := 0.0 // thousandths of an em past segX, where the viewer's pen is + for _, g := range glyphs { + if g.Text != "" { + use.mark(g.ID, []rune(g.Text)) + } else { + use.mark(g.ID, nil) + } + if !open || g.Y != segY { + flush() + p.op(nums(1, 0, 0, 1, x+g.X, y+g.Y), "Tm") + segX, segY, viewerPen, open = g.X, g.Y, 0, true + } + want := (g.X - segX) * toMil + if adj := viewerPen - want; math.Abs(adj) >= tjTolerance { + arr.WriteString(" " + ftoa(adj) + " ") + viewerPen = want + } + arr.WriteString(hexGlyph(g.ID)) + viewerPen += float64(f.glyphWidth1000(g.ID)) + p.charSpace*toMil + } + flush() + p.op("", "ET") + return nil +} + +func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) } diff --git a/glyphs_test.go b/glyphs_test.go new file mode 100644 index 0000000..f2981e6 --- /dev/null +++ b/glyphs_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package pdfkit + +import ( + "bytes" + "image" + "image/color" + "math" + "strings" + "testing" + + "github.com/go-opentype/opentype" + "rsc.io/pdf" +) + +// advPt is glyph g's advance in points at size pt, as the viewer computes it +// from /W. +func advPt(f *Font, g opentype.GlyphIndex, pt float64) float64 { + return float64(f.glyphWidth1000(g)) * pt / 1000 +} + +func TestShowGlyphsAtTheFontsOwnAdvancesWritesNoNumbers(t *testing.T) { + _, p, f := loadSynth(t) + p.SetFont(f, 10) + x1 := advPt(f, 1, 10) + x2 := x1 + advPt(f, 2, 10) + err := p.ShowGlyphs(50, 60, []Glyph{{ID: 1, Text: "H"}, {ID: 2, X: x1, Text: "i"}, {ID: 1, X: x2, Text: "H"}}) + if err != nil { + t.Fatal(err) + } + want := "1 0 0 1 50 60 Tm\n[<0001><0002><0001>] TJ\nET\n" + if c := content(p); !strings.HasSuffix(c, want) { + t.Errorf("plain run:\n%s\nwant suffix:\n%s", c, want) + } +} + +// TestShowGlyphsWritesTheCallersPositions: a glyph the layout placed 2 pt +// further than the font's advance — letter-spacing, justification — gets one +// correction of -200 thousandths at 10 pt, and the glyph after it, placed at +// its own advance from there, gets none. +func TestShowGlyphsWritesTheCallersPositions(t *testing.T) { + _, p, f := loadSynth(t) + p.SetFont(f, 10) + x1 := advPt(f, 1, 10) + 2 + x2 := x1 + advPt(f, 2, 10) + if err := p.ShowGlyphs(0, 0, []Glyph{{ID: 1}, {ID: 2, X: x1}, {ID: 1, X: x2}}); err != nil { + t.Fatal(err) + } + want := "[<0001> -200 <0002><0001>] TJ\n" + if c := content(p); !strings.Contains(c, want) { + t.Errorf("got:\n%s\nwant %q", c, want) + } +} + +// TestShowGlyphsNeverDrifts: many sub-tolerance differences must not add up. +// Every glyph is placed 0.3 thousandths of an em past the viewer's pen; the +// corrections are computed from absolute positions, so the pen is never a +// thousandth away and a correction appears only as the residue grows. +func TestShowGlyphsNeverDrifts(t *testing.T) { + _, p, f := loadSynth(t) + p.SetFont(f, 10) + var gs []Glyph + x := 0.0 + for range 40 { + gs = append(gs, Glyph{ID: 1, X: x}) + x += advPt(f, 1, 10) + 0.003 // 0.3/1000 em at 10 pt + } + if err := p.ShowGlyphs(0, 0, gs); err != nil { + t.Fatal(err) + } + // The residue reaches the tolerance every fourth glyph (4 × 0.3 = 1.2): + // nine corrections of -1.2 put the last glyph 10.8 thousandths along, its + // true place being 39 × 0.3 = 11.7, under one thousandth away. + c := content(p) + if n := strings.Count(c, " -1.2 "); n != 9 || strings.Count(c, "<0001>") != 40 { + t.Errorf("want 9 corrections of -1.2 among 40 glyphs, got %d:\n%s", n, c) + } +} + +func TestShowGlyphsStartsASegmentWhereTheBaselineMoves(t *testing.T) { + _, p, f := loadSynth(t) + p.SetFont(f, 10) + x1 := advPt(f, 1, 10) + if err := p.ShowGlyphs(10, 20, []Glyph{{ID: 1}, {ID: 2, X: x1, Y: 3}}); err != nil { + t.Fatal(err) + } + c := content(p) + if !strings.Contains(c, "1 0 0 1 10 20 Tm\n[<0001>] TJ\n") || !strings.Contains(c, "1 0 0 1 "+ftoa(10+x1)+" 23 Tm\n[<0002>] TJ\n") { + t.Errorf("a raised glyph needs its own Tm:\n%s", c) + } +} + +func TestShowGlyphsRecordsTheTextForToUnicode(t *testing.T) { + doc, p, f := loadSynth(t) + p.SetFont(f, 12) + if err := p.ShowGlyphs(0, 0, []Glyph{{ID: 1, Text: "H"}, {ID: 2, X: 9, Text: "fi"}}); err != nil { + t.Fatal(err) + } + u := doc.use[f] + if string(u.toUni[1]) != "H" || string(u.toUni[2]) != "fi" { + t.Errorf("toUni = %q, %q; want H and fi", string(u.toUni[1]), string(u.toUni[2])) + } +} + +func TestShowGlyphsRefusesBeforeWriting(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + if err := p.ShowGlyphs(0, 0, []Glyph{{ID: 1}}); err != errNoFont { + t.Errorf("no font: err = %v", err) + } + _, p, f := loadSynth(t) + p.SetFont(f, 10) + before := content(p) + for name, gs := range map[string][]Glyph{ + "glyph id past the font": {{ID: opentype.GlyphIndex(f.NumGlyphs())}}, + "NaN position": {{ID: 1, X: math.NaN()}}, + "infinite position": {{ID: 1, Y: math.Inf(1)}}, + } { + if err := p.ShowGlyphs(0, 0, gs); err == nil { + t.Errorf("%s: no error", name) + } + } + if err := p.ShowGlyphs(math.NaN(), 0, []Glyph{{ID: 1}}); err == nil { + t.Error("NaN origin: no error") + } + if got := content(p); got != before { + t.Errorf("a refused run wrote to the page:\n%s", got) + } + if err := p.ShowGlyphs(0, 0, nil); err != nil { + t.Errorf("an empty run: %v", err) + } +} + +// TestNewFontEmbedsAParsedFont: a font handed over already parsed is +// subset-embedded like a loaded one, and an independent reader finds its +// program and its /ToUnicode. +func TestNewFontEmbedsAParsedFont(t *testing.T) { + if _, err := NewFont(nil); err == nil { + t.Error("NewFont(nil): no error") + } + ot, err := opentype.Parse(synthTTF(defaultSynth())) + if err != nil { + t.Fatal(err) + } + f, err := NewFont(ot) + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + p.SetFont(f, 12) + if err := p.ShowGlyphs(72, 700, []Glyph{{ID: 1, Text: "H"}, {ID: 2, X: advPt(f, 1, 12), Text: "i"}}); err != nil { + t.Fatal(err) + } + r := reopen(t, doc) + font := r.Page(1).V.Key("Resources").Key("Font").Key("F0") + if font.Key("Subtype").Name() != "Type0" { + t.Fatalf("font subtype = %q, want Type0", font.Key("Subtype").Name()) + } + desc := font.Key("DescendantFonts").Index(0).Key("FontDescriptor") + if desc.Key("FontFile2").Kind() == pdf.Null { + t.Error("no /FontFile2: the parsed font was not embedded") + } + cmap := string(readStream(t, font.Key("ToUnicode"))) + if !strings.Contains(cmap, "<0048>") || !strings.Contains(cmap, "<0069>") { + t.Errorf("ToUnicode does not map H and i:\n%s", cmap) + } +} + +func TestNewFontRefusesWhatLoadFontRefuses(t *testing.T) { + ot, err := opentype.Parse(synthTTF(synthOpts{noCmap: true})) + if err != nil { + t.Fatal(err) + } + if _, err := NewFont(ot); err == nil { + t.Error("a font with no cmap: no error") + } + ot, err = opentype.Parse(synthTTF(synthOpts{})) + if err != nil { + t.Fatal(err) + } + f, err := NewFont(ot) + if err != nil { + t.Fatal(err) + } + if f.BaseName() != "PDFKitFont" { + t.Errorf("a font with no PostScript name: baseName = %q, want PDFKitFont", f.BaseName()) + } +} + +func TestShowGlyphsRefusesAZeroSize(t *testing.T) { + _, p, f := loadSynth(t) + p.SetFont(f, 0) + if err := p.ShowGlyphs(0, 0, []Glyph{{ID: 1}}); err == nil { + t.Error("size 0: no error") + } +} + +func TestARadialShadingIsType3(t *testing.T) { + r := shadeDoc(t, Shading{X0: 50, Y0: 50, R0: 0, X1: 50, Y1: 50, R1: 40, Radial: true, + Stops: []Stop{{0, RGB8(255, 255, 255)}, {1, RGB8(0, 0, 255)}}, ExtendEnd: true}) + sh := r.Page(1).V.Key("Resources").Key("Shading").Key("Sh0") + if got := sh.Key("ShadingType").Int64(); got != 3 { + t.Errorf("ShadingType = %d, want 3 (radial)", got) + } + co := sh.Key("Coords") + if co.Len() != 6 || co.Index(2).Float64() != 0 || co.Index(5).Float64() != 40 { + t.Errorf("Coords = %v, want [50 50 0 50 50 40]", co) + } +} + +func TestARadialShadingRefusesRadiiPDFCannotPaint(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + stops := []Stop{{0, RGB8(0, 0, 0)}, {1, RGB8(255, 255, 255)}} + for _, s := range []Shading{ + {Radial: true, R0: 0, R1: 0, Stops: stops}, + {Radial: true, R0: -1, R1: 4, Stops: stops}, + {Radial: true, R0: 0, R1: math.Inf(1), Stops: stops}, + } { + if err := p.Shade(s); err == nil { + t.Errorf("radii %v, %v: no error", s.R0, s.R1) + } + } +} + +// TestDrawImageWritesStraightColourUnderASoftMask: a half-transparent red +// pixel is (128,0,0,128) premultiplied in an *image.RGBA; with no /Matte the +// PDF must carry it straight, (255,0,0), or a viewer paints it dark red. +func TestDrawImageWritesStraightColourUnderASoftMask(t *testing.T) { + for name, img := range map[string]image.Image{ + "RGBA": &image.RGBA{Pix: []byte{128, 0, 0, 128, 0, 0, 0, 0}, Stride: 8, Rect: image.Rect(0, 0, 2, 1)}, + "NRGBA": &image.NRGBA{Pix: []byte{255, 0, 0, 128, 0, 0, 0, 0}, Stride: 8, Rect: image.Rect(0, 0, 2, 1)}, + } { + rgb, alpha, has := samples(img) + if !has || alpha[0] != 128 || alpha[1] != 0 { + t.Errorf("%s: alpha = %v (has %v)", name, alpha, has) + } + if !bytes.Equal(rgb, []byte{255, 0, 0, 0, 0, 0}) { + t.Errorf("%s: rgb = %v, want straight [255 0 0 0 0 0]", name, rgb) + } + } + // A malformed premultiplied pixel (colour above its alpha) saturates + // rather than wrapping. + bad := &image.RGBA{Pix: []byte{200, 0, 0, 100}, Stride: 4, Rect: image.Rect(0, 0, 1, 1)} + if rgb, _, _ := samples(bad); rgb[0] != 255 { + t.Errorf("colour above alpha: r = %d, want 255", rgb[0]) + } + // Opaque pixels are untouched. + op := image.NewRGBA(image.Rect(0, 0, 1, 1)) + op.Set(0, 0, color.RGBA{10, 20, 30, 255}) + if rgb, _, has := samples(op); has || !bytes.Equal(rgb, []byte{10, 20, 30}) { + t.Errorf("opaque: rgb = %v, has alpha %v", rgb, has) + } +} diff --git a/image.go b/image.go index 2f8588e..108afde 100644 --- a/image.go +++ b/image.go @@ -81,9 +81,16 @@ func (p *Page) DrawImage(img image.Image, r Rect) { p.placeImage(x, r) } -// samples splits an image into 8-bit RGB samples and alpha samples, the -// colour channels as image.Image's RGBA method gives them (alpha- -// premultiplied), and reports whether any pixel is not fully opaque. +// samples splits an image into 8-bit RGB samples and alpha samples, and +// reports whether any pixel is not fully opaque. +// +// The colour samples are written straight, NOT alpha-premultiplied: an image +// with a soft mask whose /Matte entry is absent "shall not be preblended" +// (ISO 32000-1 §11.6.5.3, Table 145), and a viewer composites its samples +// as given. image.Image's RGBA method and an *image.RGBA's Pix are both +// premultiplied, so every pixel that is neither opaque nor fully transparent +// is divided back by its alpha here; before, a half-transparent edge came out +// darkened towards black. // // An *image.RGBA already stores exactly those bytes, so it is copied row by // row; any other image goes through At, which boxes every pixel in an @@ -102,6 +109,7 @@ func samples(img image.Image) (rgb, alpha []byte, hasAlpha bool) { alpha[a] = row[i+3] if row[i+3] != 0xff { hasAlpha = true + unpremultiply(rgb[o:o+3], row[i+3]) } o += 3 a++ @@ -117,6 +125,7 @@ func samples(img image.Image) (rgb, alpha []byte, hasAlpha bool) { alpha[a] = byte(ca >> 8) if ca != 0xffff { hasAlpha = true + unpremultiply(rgb[o:o+3], byte(ca>>8)) } o += 3 a++ @@ -125,6 +134,22 @@ func samples(img image.Image) (rgb, alpha []byte, hasAlpha bool) { return rgb, alpha, hasAlpha } +// unpremultiply turns three premultiplied 8-bit samples into straight ones +// for alpha a, rounding to nearest. A fully transparent pixel's colour is +// meaningless and is left as it is (premultiplied, it is 0 already). +func unpremultiply(c []byte, a byte) { + if a == 0 || a == 0xff { + return + } + for i := range c { + v := (int(c[i])*255 + int(a)/2) / int(a) + if v > 255 { + v = 255 + } + c[i] = byte(v) + } +} + // DrawPNG decodes PNG bytes and embeds the image into r. It returns an error if // the data is not a valid PNG. func (p *Page) DrawPNG(data []byte, r Rect) error { diff --git a/shading.go b/shading.go index c7b5223..93a609b 100644 --- a/shading.go +++ b/shading.go @@ -17,7 +17,8 @@ type Stop struct { } // A Shading is an axial gradient: colour interpolated along the line from -// (X0,Y0) to (X1,Y1), in the page's user space. +// (X0,Y0) to (X1,Y1), in the page's user space (ISO 32000-1 §8.7.4.5.3, a +// type 2 shading). // // It is what a logo, a chart's fill and a printed background all need, and // until now the only way to put one in a PDF from here was to rasterise it — @@ -27,11 +28,20 @@ type Stop struct { // Extend says whether the end colours carry on past the ends of the axis. // Without them the area beyond each end is left unpainted, which for a gradient // used as a background shows as two hard bands of nothing. +// +// With Radial set it is a radial gradient instead (ISO 32000-1 §8.7.4.5.4, +// a type 3 shading): colour interpolated from the circle of radius R0 centred +// on (X0,Y0) to the circle of radius R1 centred on (X1,Y1). A CSS +// radial-gradient is the case X0,Y0 = X1,Y1 and R0 = 0; an elliptical one is +// that circle drawn under a Transform that scales one axis. type Shading struct { X0, Y0, X1, Y1 float64 Stops []Stop ExtendStart bool ExtendEnd bool + + Radial bool + R0, R1 float64 } // Shade paints the gradient over the current clip. @@ -102,9 +112,23 @@ func (s Shading) dict() (*pdfDict, error) { } d := newDict() - d.set("ShadingType", pdfInt(2)) - d.set("ColorSpace", space) - d.set("Coords", pdfArray{pdfReal(s.X0), pdfReal(s.Y0), pdfReal(s.X1), pdfReal(s.Y1)}) + if s.Radial { + // §8.7.4.5.4: both radii shall be >= 0, and with both 0 nothing is + // painted — which is never what a caller asking for a gradient meant. + if s.R0 < 0 || s.R1 < 0 || !finite(s.R0) || !finite(s.R1) { + return nil, fmt.Errorf("pdfkit: radial shading radii %v and %v; both must be finite and >= 0", s.R0, s.R1) + } + if s.R0 == 0 && s.R1 == 0 { + return nil, fmt.Errorf("pdfkit: radial shading with both radii 0 paints nothing") + } + d.set("ShadingType", pdfInt(3)) + d.set("ColorSpace", space) + d.set("Coords", pdfArray{pdfReal(s.X0), pdfReal(s.Y0), pdfReal(s.R0), pdfReal(s.X1), pdfReal(s.Y1), pdfReal(s.R1)}) + } else { + d.set("ShadingType", pdfInt(2)) + d.set("ColorSpace", space) + d.set("Coords", pdfArray{pdfReal(s.X0), pdfReal(s.Y0), pdfReal(s.X1), pdfReal(s.Y1)}) + } d.set("Function", stitch(stops)) d.set("Extend", pdfArray{pdfBool(s.ExtendStart), pdfBool(s.ExtendEnd)}) return d, nil