Skip to content

Commit

Permalink
Support stroke view transformation for raster, PDF, and SVG, see #74
Browse files Browse the repository at this point in the history
  • Loading branch information
tdewolff committed Jun 16, 2021
1 parent 25aa794 commit e36919c
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 64 deletions.
31 changes: 22 additions & 9 deletions renderers/pdf/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,7 @@ func (r *PDF) Size() (float64, float64) {

// RenderPath renders a path to the canvas using a style and a transformation matrix.
func (r *PDF) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) {
fill := style.HasFill()
stroke := style.HasStroke()
differentAlpha := fill && stroke && style.FillColor.A != style.StrokeColor.A
differentAlpha := style.HasFill() && style.HasStroke() && style.FillColor.A != style.StrokeColor.A

// PDFs don't support the arcs joiner, miter joiner (not clipped), or miter joiner (clipped) with non-bevel fallback
strokeUnsupported := false
Expand All @@ -99,6 +97,20 @@ func (r *PDF) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
strokeUnsupported = true
}
}
if !strokeUnsupported {
if m.IsSimilarity() {
scale := math.Sqrt(m.Det())
style.StrokeWidth *= scale
style.DashOffset *= scale
dashes := make([]float64, len(style.Dashes))
for i := range style.Dashes {
dashes[i] = style.Dashes[i] * scale
}
style.Dashes = dashes
} else {
strokeUnsupported = true
}
}

// PDFs don't support connecting first and last dashes if path is closed, so we move the start of the path if this is the case
// TODO: closing dashes
Expand All @@ -113,16 +125,16 @@ func (r *PDF) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
closed = true
}

if !stroke || !strokeUnsupported {
if fill && !stroke {
if !style.HasStroke() || !strokeUnsupported {
if style.HasFill() && !style.HasStroke() {
r.w.SetFillColor(style.FillColor)
r.w.Write([]byte(" "))
r.w.Write([]byte(data))
r.w.Write([]byte(" f"))
if style.FillRule == canvas.EvenOdd {
r.w.Write([]byte("*"))
}
} else if !fill && stroke {
} else if !style.HasFill() && style.HasStroke() {
r.w.SetStrokeColor(style.StrokeColor)
r.w.SetLineWidth(style.StrokeWidth)
r.w.SetLineCap(style.StrokeCapper)
Expand All @@ -138,7 +150,7 @@ func (r *PDF) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
if style.FillRule == canvas.EvenOdd {
r.w.Write([]byte("*"))
}
} else if fill && stroke {
} else if style.HasFill() && style.HasStroke() {
if !differentAlpha {
r.w.SetFillColor(style.FillColor)
r.w.SetStrokeColor(style.StrokeColor)
Expand Down Expand Up @@ -183,8 +195,8 @@ func (r *PDF) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
}
}
} else {
// stroke && strokeUnsupported
if fill {
// style.HasStroke() && strokeUnsupported
if style.HasFill() {
r.w.SetFillColor(style.FillColor)
r.w.Write([]byte(" "))
r.w.Write([]byte(data))
Expand All @@ -199,6 +211,7 @@ func (r *PDF) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
path = path.Dash(style.DashOffset, style.Dashes...)
}
path = path.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner)
path = path.Transform(m)

r.w.SetFillColor(style.StrokeColor)
r.w.Write([]byte(" "))
Expand Down
40 changes: 23 additions & 17 deletions renderers/rasterizer/rasterizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,31 @@ func (r *Rasterizer) Size() (float64, float64) {
// RenderPath renders a path to the canvas using a style and a transformation matrix.
func (r *Rasterizer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) {
// TODO: use fill rule (EvenOdd, NonZero) for rasterizer
path = path.Transform(m)

strokeWidth := 0.0
fill := path
stroke := path
bounds := canvas.Rect{}
if style.HasFill() {
fill = path.Transform(m)
if !style.HasStroke() {
bounds = fill.Bounds()
}
}
if style.HasStroke() {
strokeWidth = style.StrokeWidth
if 0 < len(style.Dashes) {
stroke = stroke.Dash(style.DashOffset, style.Dashes...)
}
stroke = stroke.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner)
stroke = stroke.Transform(m)
bounds = stroke.Bounds()
}

size := r.img.Bounds().Size()
bounds := path.Bounds()
dx, dy := 0, 0
dpmm := r.resolution.DPMM()
x := int((bounds.X - strokeWidth) * dpmm)
y := int((bounds.Y - strokeWidth) * dpmm)
w := int((bounds.W+2*strokeWidth)*dpmm) + 1
h := int((bounds.H+2*strokeWidth)*dpmm) + 1
x := int(bounds.X * dpmm)
y := int(bounds.Y * dpmm)
w := int(bounds.W*dpmm) + 1
h := int(bounds.H*dpmm) + 1
if (x+w <= 0 || size.X <= x) && (y+h <= 0 || size.Y <= y) {
return // outside canvas
}
Expand All @@ -118,20 +128,16 @@ func (r *Rasterizer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.
return // has no size
}

path = path.Translate(-float64(x)/dpmm, -float64(y)/dpmm)
if style.HasFill() {
ras := vector.NewRasterizer(w, h)
path.ToRasterizer(ras, r.resolution)
fill = fill.Translate(-float64(x)/dpmm, -float64(y)/dpmm)
fill.ToRasterizer(ras, r.resolution)
ras.Draw(r.img, image.Rect(x, size.Y-y, x+w, size.Y-y-h), image.NewUniform(style.FillColor), image.Point{dx, dy})
}
if style.HasStroke() {
if 0 < len(style.Dashes) {
path = path.Dash(style.DashOffset, style.Dashes...)
}
path = path.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner)

ras := vector.NewRasterizer(w, h)
path.ToRasterizer(ras, r.resolution)
stroke = stroke.Translate(-float64(x)/dpmm, -float64(y)/dpmm)
stroke.ToRasterizer(ras, r.resolution)
ras.Draw(r.img, image.Rect(x, size.Y-y, x+w, size.Y-y-h), image.NewUniform(style.StrokeColor), image.Point{dx, dy})
}
}
Expand Down
37 changes: 25 additions & 12 deletions renderers/svg/svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,7 @@ func (r *SVG) Size() (float64, float64) {

// RenderPath renders a path to the canvas using a style and a transformation matrix.
func (r *SVG) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) {
fill := style.HasFill()
stroke := style.HasStroke()

stroke := path
path = path.Transform(canvas.Identity.ReflectYAbout(r.height / 2.0).Mul(m))
fmt.Fprintf(r.w, `<path d="%s`, path.ToSVG())

Expand All @@ -166,9 +164,23 @@ func (r *SVG) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
strokeUnsupported = true
}
}
if !strokeUnsupported {
if m.IsSimilarity() {
scale := math.Sqrt(m.Det())
style.StrokeWidth *= scale
style.DashOffset *= scale
dashes := make([]float64, len(style.Dashes))
for i := range style.Dashes {
dashes[i] = style.Dashes[i] * scale
}
style.Dashes = dashes
} else {
strokeUnsupported = true
}
}

if !stroke {
if fill {
if !style.HasStroke() {
if style.HasFill() {
if style.FillColor != canvas.Black {
fmt.Fprintf(r.w, `" fill="%v`, canvas.CSSColor(style.FillColor))
}
Expand All @@ -180,7 +192,7 @@ func (r *SVG) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
}
} else {
b := &strings.Builder{}
if fill {
if style.HasFill() {
if style.FillColor != canvas.Black {
fmt.Fprintf(b, ";fill:%v", canvas.CSSColor(style.FillColor))
}
Expand All @@ -190,7 +202,7 @@ func (r *SVG) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
} else {
fmt.Fprintf(b, ";fill:none")
}
if stroke && !strokeUnsupported {
if style.HasStroke() && !strokeUnsupported {
fmt.Fprintf(b, `;stroke:%v`, canvas.CSSColor(style.StrokeColor))
if style.StrokeWidth != 1.0 {
fmt.Fprintf(b, ";stroke-width:%v", dec(style.StrokeWidth))
Expand Down Expand Up @@ -237,13 +249,14 @@ func (r *SVG) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
r.writeClasses(r.w)
fmt.Fprintf(r.w, `"/>`)

if stroke && strokeUnsupported {
// stroke settings unsupported by PDF, draw stroke explicitly
if style.HasStroke() && strokeUnsupported {
// stroke settings unsupported by SVG, draw stroke explicitly
if style.IsDashed() {
path = path.Dash(style.DashOffset, style.Dashes...)
stroke = stroke.Dash(style.DashOffset, style.Dashes...)
}
path = path.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner)
fmt.Fprintf(r.w, `<path d="%s`, path.ToSVG())
stroke = stroke.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner)
stroke = stroke.Transform(canvas.Identity.ReflectYAbout(r.height / 2.0).Mul(m))
fmt.Fprintf(r.w, `<path d="%s`, stroke.ToSVG())
if style.StrokeColor != canvas.Black {
fmt.Fprintf(r.w, `" fill="%v`, canvas.CSSColor(style.StrokeColor))
}
Expand Down
62 changes: 48 additions & 14 deletions renderers/tex/tex.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,7 @@ func (r *TeX) getColor(col color.RGBA) string {
return name
}

// RenderPath renders a path to the canvas using a style and a transformation matrix.
func (r *TeX) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) {
if path.Empty() {
return
}
path = path.Transform(m)

func (r *TeX) writePath(path *canvas.Path) {
for _, seg := range path.Segments() {
end := seg.End
switch seg.Cmd {
Expand Down Expand Up @@ -99,11 +93,33 @@ func (r *TeX) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
fmt.Fprintf(r.w, "\n\\pgfpathclose")
}
}
}

fill := style.HasFill()
stroke := style.HasStroke()
// RenderPath renders a path to the canvas using a style and a transformation matrix.
func (r *TeX) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) {
if path.Empty() {
return
}

if fill {
stroke := path
path = path.Transform(m)

strokeUnsupported := false
if m.IsSimilarity() {
scale := math.Sqrt(m.Det())
style.StrokeWidth *= scale
style.DashOffset *= scale
dashes := make([]float64, len(style.Dashes))
for i := range style.Dashes {
dashes[i] = style.Dashes[i] * scale
}
style.Dashes = dashes
} else {
strokeUnsupported = true
}
r.writePath(path)

if style.HasFill() {
if style.FillColor.R != r.style.FillColor.R || style.FillColor.G != r.style.FillColor.G || style.FillColor.B != r.style.FillColor.B {
fmt.Fprintf(r.w, "\n\\pgfsetfillcolor{%v}", r.getColor(style.FillColor))
}
Expand All @@ -112,7 +128,7 @@ func (r *TeX) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
}
}

if stroke {
if style.HasStroke() && !strokeUnsupported {
if style.StrokeCapper != r.style.StrokeCapper {
if _, ok := style.StrokeCapper.(canvas.RoundCapper); ok {
fmt.Fprintf(r.w, "\n\\pgfsetroundcap")
Expand Down Expand Up @@ -161,13 +177,31 @@ func (r *TeX) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix)
fmt.Fprintf(r.w, "\n\\pgfsetstrokeopacity{%v}", dec(float64(style.StrokeColor.A)/255.0))
}
}
if fill && stroke {
if style.HasFill() && style.HasStroke() && !strokeUnsupported {
fmt.Fprintf(r.w, "\n\\pgfusepath{fill,stroke}")
} else if fill {
} else if style.HasFill() {
fmt.Fprintf(r.w, "\n\\pgfusepath{fill}")
} else if stroke {
} else if style.HasStroke() && !strokeUnsupported {
fmt.Fprintf(r.w, "\n\\pgfusepath{stroke}")
}

if style.HasStroke() && strokeUnsupported {
// stroke settings unsupported by TeX, draw stroke explicitly
if style.IsDashed() {
stroke = stroke.Dash(style.DashOffset, style.Dashes...)
}
stroke = stroke.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner)
stroke = stroke.Transform(m)
r.writePath(stroke)
if style.StrokeColor.R != r.style.StrokeColor.R || style.StrokeColor.G != r.style.StrokeColor.G || style.StrokeColor.B != r.style.StrokeColor.B {
fmt.Fprintf(r.w, "\n\\pgfsetfillcolor{%v}", r.getColor(style.StrokeColor))
}
if style.StrokeColor.A != r.style.StrokeColor.A {
fmt.Fprintf(r.w, "\n\\pgfsetfillopacity{%v}", dec(float64(style.StrokeColor.A)/255.0))
}
fmt.Fprintf(r.w, "\n\\pgfusepath{fill}")

}
r.style = style
}

Expand Down
23 changes: 14 additions & 9 deletions util.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,20 +454,25 @@ func (m Matrix) Decompose() (float64, float64, float64, float64, float64, float6
return m[0][2], m[1][2], theta, sx, sy, phi
}

// IsTranslation is true if the matrix consists of only translational components, i.e. no rotation, scaling or skew.
// IsTranslation is true if the matrix consists of only translational components, i.e. no rotation, scaling, or skew transformations.
func (m Matrix) IsTranslation() bool {
return Equal(m[0][0], 1.0) && Equal(m[0][1], 0.0) && Equal(m[1][0], 0.0) && Equal(m[1][1], 1.0)
}

// IsRigid is true if the matrix consists of only (proper) rigid transformations, i.e. no scaling or skew.
// IsRigid is true if the matrix is orthogonal and consists of only translation, rotation, and reflection transformations.
func (m Matrix) IsRigid() bool {
if !Equal(m.Det(), 1.0) {
return false
}
invM := m.Inv()
invM[0][2] = m[0][2]
invM[1][2] = m[1][2]
return m.T().Equals(invM)
a := m[0][0]*m[0][0] + m[0][1]*m[0][1]
b := m[1][0]*m[1][0] + m[1][1]*m[1][1]
c := m[0][0]*m[1][0] + m[0][1]*m[1][1]
return Equal(a, 1.0) && Equal(b, 1.0) && Equal(c, 0.0)
}

// IsSimilarity is true if the matrix consists of only translation, rotation, reflection, and scaling transformations.
func (m Matrix) IsSimilarity() bool {
a := m[0][0]*m[0][0] + m[0][1]*m[0][1]
b := m[1][0]*m[1][0] + m[1][1]*m[1][1]
c := m[0][0]*m[1][0] + m[0][1]*m[1][1]
return Equal(a, b) && Equal(c, 0.0)
}

// Equals returns true if both matrices are equal with a tolerance of Epsilon.
Expand Down
19 changes: 16 additions & 3 deletions util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,26 @@ func TestMatrix(t *testing.T) {
test.Float(t, sy, 1.0)
test.Float(t, phi, -90.0)

test.T(t, Identity.Translate(1.0, 1.0).IsTranslation(), true)
test.T(t, Identity.Rotate(90.0).IsTranslation(), false)
test.T(t, Identity.Scale(-1.0, 1.0).IsTranslation(), false)
test.T(t, Identity.Scale(2.0, 2.0).IsTranslation(), false)
test.T(t, Identity.Scale(2.0, 1.0).IsTranslation(), false)
test.T(t, Identity.Shear(2.0, -1.0).IsTranslation(), false)

test.T(t, Identity.Translate(1.0, 1.0).IsRigid(), true)
test.T(t, Identity.Rotate(90.0).IsRigid(), true)
test.T(t, Identity.Scale(-1.0, 1.0).IsRigid(), true)
test.T(t, Identity.Scale(2.0, 2.0).IsRigid(), false)
test.T(t, Identity.Scale(2.0, 1.0).IsRigid(), false)
test.T(t, Identity.Scale(-1.0, 1.0).IsRigid(), false)
test.T(t, Identity.Shear(2.0, -1.0).IsRigid(), false)
test.T(t, Identity.Translate(1.0, 1.0).IsTranslation(), true)
test.T(t, Identity.Rotate(90.0).IsTranslation(), false)

test.T(t, Identity.Translate(1.0, 1.0).IsSimilarity(), true)
test.T(t, Identity.Rotate(90.0).IsSimilarity(), true)
test.T(t, Identity.Scale(-1.0, 1.0).IsSimilarity(), true)
test.T(t, Identity.Scale(2.0, 2.0).IsSimilarity(), true)
test.T(t, Identity.Scale(2.0, 1.0).IsSimilarity(), false)
test.T(t, Identity.Shear(2.0, -1.0).IsSimilarity(), false)

x, y := Identity.Translate(p.X, p.Y).Pos()
test.Float(t, x, p.X)
Expand Down

0 comments on commit e36919c

Please sign in to comment.