diff --git a/go.mod b/go.mod index c4030ee..5e90c99 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/dhconnelly/rtreego v1.2.0 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef + github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f github.com/stretchr/testify v1.11.1 github.com/yuin/gopher-lua v1.1.2 golang.org/x/image v0.43.0 diff --git a/go.sum b/go.sum index 97ab191..a22cb9a 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiY github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f h1:uLR2GaV0kWYZ3Ns3l3sjtiN+mOWAQadvrL8HXcyKjl0= +github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f/go.mod h1:LZwgIPG9X6nH6j5Ef+xMFspl6Hru4b5EJxzMfeqHYJY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/pkg/s100/symbols/symbols.go b/pkg/s100/symbols/symbols.go index 034463c..cce85db 100644 --- a/pkg/s100/symbols/symbols.go +++ b/pkg/s100/symbols/symbols.go @@ -3,10 +3,13 @@ // and rasterizes them in pure Go. It is the single source of truth for // SVG→raster, used by the sprite-atlas builder. // -// Two oksvg defects are worked around here: it ignores a non-zero viewBox -// origin (we normalize to "0 0 W H" and wrap the content in a translate), and -// it applies stroke-width in device px without scaling by the draw transform -// (we pre-multiply stroke-width by the px/mm scale). +// Three oksvg/rasterx defects are worked around here: oksvg ignores a non-zero +// viewBox origin (we normalize to "0 0 W H" and wrap the content in a +// translate); it applies stroke-width in device px without scaling by the draw +// transform (we pre-multiply stroke-width by the px/mm scale); and it ignores +// the fill-rule attribute while rasterx's ScannerGV cannot do even-odd fills at +// all (we force even-odd winding and rasterize through scanFT, which honours +// it) so the catalogue's even-odd danger symbols don't fill their holes solid. package symbols import ( @@ -23,6 +26,7 @@ import ( "github.com/srwiley/oksvg" "github.com/srwiley/rasterx" + "github.com/srwiley/scanFT" ) var cssRuleRE = regexp.MustCompile(`\.([A-Za-z0-9_]+)\s*\{([^}]*)\}`) @@ -58,6 +62,17 @@ func Render(svg []byte, css map[string]string, pxPerMM float64) (*Rendered, erro if err != nil { return nil, err } + // oksvg ignores the SVG fill-rule attribute and always fills with the + // nonzero winding rule. Every S-101 symbol is authored with + // fill-rule="evenodd": the danger hatch of ISODGR01/DANGER0x and similar + // glyphs is a single compound path whose inner subpath is a hole, and + // nonzero winding fills that hole solid (an ISODGR01 with no star in it). + // Force even-odd winding on every path so the holes render. For simple, + // non-self-intersecting paths even-odd and nonzero are identical, so this + // is safe for the rest of the set. + for i := range icon.SVGPaths { + icon.SVGPaths[i].UseNonZeroWinding = false + } w := int(math.Ceil(vb[2] * pxPerMM)) h := int(math.Ceil(vb[3] * pxPerMM)) if w < 1 || h < 1 { @@ -65,7 +80,10 @@ func Render(svg []byte, css map[string]string, pxPerMM float64) (*Rendered, erro } rgba := image.NewRGBA(image.Rect(0, 0, w, h)) icon.SetTarget(0, 0, float64(w), float64(h)) - scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) + // scanFT (freetype-backed) honours the even-odd winding set above; + // rasterx's own ScannerGV silently ignores it (it is nonzero-only), which + // fills the danger-symbol holes solid. + scanner := scanFT.NewScannerFT(w, h, scanFT.NewRGBAPainter(rgba)) icon.Draw(rasterx.NewDasher(w, h, scanner), 1.0) // rasterx writes alpha-premultiplied RGBA; draw.Src into NRGBA converts to diff --git a/pkg/s100/symbols/symbols_test.go b/pkg/s100/symbols/symbols_test.go new file mode 100644 index 0000000..860c7e1 --- /dev/null +++ b/pkg/s100/symbols/symbols_test.go @@ -0,0 +1,38 @@ +package symbols + +import "testing" + +// TestRenderEvenOddHole guards the fill-rule="evenodd" handling. Every S-101 +// symbol is authored with even-odd winding; the ISODGR01/DANGER0x danger glyphs +// carve their inner shape out of the outer body with a single compound path +// whose inner subpath is a hole. oksvg ignores fill-rule and fills nonzero, +// which would fill that hole solid (the "ISODGR01 with no star in it" bug). +func TestRenderEvenOddHole(t *testing.T) { + // Outer 8x8 square with a concentric 4x4 inner subpath, both wound the same + // way. Under nonzero winding the inner square fills solid; under even-odd it + // is a hole. fill-rule="evenodd" sits on the root exactly as the + // catalogue authors it. + svg := []byte(` + + +`) + css := map[string]string{"fX": "fill:#C045D1"} + + const pxPerMM = 10 + r, err := Render(svg, css, pxPerMM) + if err != nil { + t.Fatalf("Render: %v", err) + } + + // Pivot is the SVG origin (0,0); the hole is centred there. + cx, cy := int(r.PivotX), int(r.PivotY) + if _, _, _, a := r.Image.At(cx, cy).RGBA(); a != 0 { + t.Errorf("centre pixel (%d,%d) alpha=%d, want 0 (even-odd hole filled solid — fill-rule ignored)", cx, cy, a>>8) + } + + // A pixel in the ring between the two squares must be painted. + rx, ry := int(r.PivotX+3*pxPerMM), cy // svg (3,0) + if _, _, _, a := r.Image.At(rx, ry).RGBA(); a == 0 { + t.Errorf("ring pixel (%d,%d) is transparent, want painted", rx, ry) + } +}