diff --git a/vmap/encoder_fast.go b/vmap/encoder_fast.go new file mode 100644 index 0000000..589e00c --- /dev/null +++ b/vmap/encoder_fast.go @@ -0,0 +1,398 @@ +package vmap + +import ( + "strconv" +) + +// MarshalVmap marshals a VMAP to XML, producing output identical to encoding/xml.Marshal. +func MarshalVmap(v *VMAP) ([]byte, error) { + buf := make([]byte, 0, 8192) + buf = appendVMAP(buf, v) + return buf, nil +} + +// MarshalVast marshals a VAST to XML, producing output identical to encoding/xml.Marshal. +func MarshalVast(v *VAST) ([]byte, error) { + buf := make([]byte, 0, 4096) + buf = appendVAST(buf, v) + return buf, nil +} + +// --- escape helpers --- + +// escText escapes text content, matching encoding/xml.EscapeText. +func escText(buf []byte, s string) []byte { + last := 0 + for i := 0; i < len(s); i++ { + var esc string + switch s[i] { + case '&': + esc = "&" + case '<': + esc = "<" + case '>': + esc = ">" + case '"': + esc = """ + case '\t': + esc = " " + case '\n': + esc = " " + case '\r': + esc = " " + default: + continue + } + buf = append(buf, s[last:i]...) + buf = append(buf, esc...) + last = i + 1 + } + return append(buf, s[last:]...) +} + +// escAttr escapes attribute values, matching encoding/xml attribute escaping. +func escAttr(buf []byte, s string) []byte { + last := 0 + for i := 0; i < len(s); i++ { + var esc string + switch s[i] { + case '&': + esc = "&" + case '<': + esc = "<" + case '>': + esc = ">" + case '"': + esc = """ + case '\t': + esc = " " + case '\n': + esc = " " + case '\r': + esc = " " + default: + continue + } + buf = append(buf, s[last:i]...) + buf = append(buf, esc...) + last = i + 1 + } + return append(buf, s[last:]...) +} + +// --- duration / time offset helpers (allocation-free) --- + +func append2dig(buf []byte, n int) []byte { + return append(buf, byte('0'+n/10), byte('0'+n%10)) +} + +func append3dig(buf []byte, n int) []byte { + return append(buf, byte('0'+n/100), byte('0'+(n/10)%10), byte('0'+n%10)) +} + +func appendDuration(buf []byte, d Duration) []byte { + dur := d.Duration + if dur == 0 { + return append(buf, "00:00:00"...) + } + h := int(dur.Hours()) + m := int(dur.Minutes()) % 60 + s := int(dur.Seconds()) % 60 + ms := int(dur.Milliseconds()) % 1000 + buf = append2dig(buf, h) + buf = append(buf, ':') + buf = append2dig(buf, m) + buf = append(buf, ':') + buf = append2dig(buf, s) + if ms > 0 { + buf = append(buf, '.') + buf = append3dig(buf, ms) + } + return buf +} + +func appendTimeOffset(buf []byte, to TimeOffset) []byte { + if to.Duration != nil { + return appendDuration(buf, *to.Duration) + } + if to.Position != 0 { + buf = append(buf, '#') + return strconv.AppendInt(buf, int64(to.Position), 10) + } + if to.Percent != 0 { + buf = strconv.AppendFloat(buf, float64(to.Percent*100), 'f', 6, 32) + return append(buf, '%') + } + return buf +} + +// --- struct encoders --- +// Field and attribute order matches encoding/xml.Marshal exactly. + +func appendVMAP(buf []byte, v *VMAP) []byte { + // XMLName tag is xml:"VMAP" (name only) — xml.Marshal does not output xmlns + buf = append(buf, `') + + // chardata (Text field, before child elements, matching xml.Marshal field order) + buf = escText(buf, v.Text) + + for i := range v.AdBreaks { + buf = appendAdBreak(buf, &v.AdBreaks[i]) + } + buf = append(buf, ""...) + return buf +} + +func appendAdBreak(buf []byte, ab *AdBreak) []byte { + // attrs: breakId, breakType, timeOffset + buf = append(buf, `') + + // child elements in field order: AdSource, TrackingEvents + if ab.AdSource != nil { + buf = appendAdSource(buf, ab.AdSource) + } + // Wrapper always emitted for nested path xml:"TrackingEvents>Tracking" + buf = append(buf, ""...) + for i := range ab.TrackingEvents { + buf = appendTracking(buf, &ab.TrackingEvents[i]) + } + buf = append(buf, ""...) + buf = append(buf, ""...) + return buf +} + +func appendAdSource(buf []byte, as *AdSource) []byte { + buf = append(buf, ""...) + if as.VASTData != nil { + buf = append(buf, ""...) + if as.VASTData.VAST != nil { + buf = appendVAST(buf, as.VASTData.VAST) + } + buf = append(buf, ""...) + } + buf = append(buf, ""...) + return buf +} + +func appendVAST(buf []byte, v *VAST) []byte { + // attrs: xsi, noNamespaceSchemaLocation, version + buf = append(buf, `') + + // chardata + buf = escText(buf, v.Text) + + for i := range v.Ad { + buf = appendAd(buf, &v.Ad[i]) + } + buf = append(buf, ""...) + return buf +} + +func appendAd(buf []byte, ad *Ad) []byte { + buf = append(buf, `') + + if ad.InLine != nil { + buf = appendInLine(buf, ad.InLine) + } + buf = append(buf, ""...) + return buf +} + +func appendInLine(buf []byte, il *InLine) []byte { + buf = append(buf, ""...) + + // field order: AdSystem, AdTitle, Impression, Creatives, Extensions, Error + buf = append(buf, ""...) + buf = escText(buf, il.AdSystem) + buf = append(buf, ""...) + + buf = append(buf, ""...) + buf = escText(buf, il.AdTitle) + buf = append(buf, ""...) + + for i := range il.Impression { + buf = appendImpression(buf, &il.Impression[i]) + } + + // Wrappers always emitted for nested paths + buf = append(buf, ""...) + for i := range il.Creatives { + buf = appendCreative(buf, &il.Creatives[i]) + } + buf = append(buf, ""...) + + buf = append(buf, ""...) + for i := range il.Extensions { + buf = appendExtension(buf, &il.Extensions[i]) + } + buf = append(buf, ""...) + + if il.Error != nil { + buf = append(buf, ""...) + buf = escText(buf, il.Error.Value) + buf = append(buf, ""...) + } + + buf = append(buf, ""...) + return buf +} + +func appendImpression(buf []byte, imp *Impression) []byte { + buf = append(buf, `') + buf = escText(buf, imp.Text) + buf = append(buf, ""...) + return buf +} + +func appendCreative(buf []byte, c *Creative) []byte { + buf = append(buf, `') + + if c.UniversalAdId != nil { + buf = append(buf, `') + buf = escText(buf, c.UniversalAdId.Id) + buf = append(buf, ""...) + } + + if c.Linear != nil { + buf = appendLinear(buf, c.Linear) + } + + buf = append(buf, ""...) + return buf +} + +func appendLinear(buf []byte, l *Linear) []byte { + buf = append(buf, ""...) + + // Duration + buf = append(buf, ""...) + buf = appendDuration(buf, l.Duration) + buf = append(buf, ""...) + + // Wrappers always emitted for nested paths + buf = append(buf, ""...) + for i := range l.TrackingEvents { + buf = appendTracking(buf, &l.TrackingEvents[i]) + } + buf = append(buf, ""...) + + buf = append(buf, ""...) + for i := range l.MediaFiles { + buf = appendMediaFile(buf, &l.MediaFiles[i]) + } + buf = append(buf, ""...) + + // VideoClicks (shared wrapper for ClickThrough, ClickTracking, CustomClick) + buf = append(buf, ""...) + if l.ClickThrough != nil { + buf = append(buf, `') + buf = escText(buf, l.ClickThrough.Text) + buf = append(buf, ""...) + } + for i := range l.ClickTracking { + buf = append(buf, `') + buf = escText(buf, l.ClickTracking[i].Text) + buf = append(buf, ""...) + } + for i := range l.CustomClick { + buf = append(buf, `') + buf = escText(buf, l.CustomClick[i].Text) + buf = append(buf, ""...) + } + buf = append(buf, ""...) + + buf = append(buf, ""...) + return buf +} + +func appendTracking(buf []byte, t *TrackingEvent) []byte { + buf = append(buf, `') + buf = escText(buf, t.Text) + buf = append(buf, ""...) + return buf +} + +func appendMediaFile(buf []byte, m *MediaFile) []byte { + // attr order: bitrate, width, height, delivery, type, codec + buf = append(buf, `') + buf = escText(buf, m.Text) + buf = append(buf, ""...) + return buf +} + +func appendExtension(buf []byte, ext *Extension) []byte { + buf = append(buf, `') + + buf = append(buf, ""...) + for i := range ext.CreativeParameters { + buf = appendCreativeParameter(buf, &ext.CreativeParameters[i]) + } + buf = append(buf, ""...) + + buf = append(buf, ""...) + return buf +} + +func appendCreativeParameter(buf []byte, cp *CreativeParameter) []byte { + // attr order: creativeId, name, type (Value is chardata) + buf = append(buf, `') + buf = escText(buf, cp.Value) + buf = append(buf, ""...) + return buf +} diff --git a/vmap/structure_test.go b/vmap/structure_test.go index 88f8167..697c849 100644 --- a/vmap/structure_test.go +++ b/vmap/structure_test.go @@ -325,6 +325,196 @@ func TestSpecialCharacters(t *testing.T) { is.Equal(vastDecoded.Ad[0].InLine.AdTitle, "Hej&ö\n<>\"") } +// --- Fast Marshal Tests --- + +func TestMarshalVmapFast(t *testing.T) { + is := is.New(t) + doc, err := os.ReadFile("sample-vmap/testVmap.xml") + is.NoErr(err) + + var v VMAP + err = xml.Unmarshal(doc, &v) + is.NoErr(err) + + expected, err := xml.Marshal(v) + is.NoErr(err) + + got, err := MarshalVmap(&v) + is.NoErr(err) + + is.Equal(string(expected), string(got)) +} + +func TestMarshalVmapEmptyFast(t *testing.T) { + is := is.New(t) + doc, err := os.ReadFile("sample-vmap/testVmap2.xml") + is.NoErr(err) + + var v VMAP + err = xml.Unmarshal(doc, &v) + is.NoErr(err) + + expected, err := xml.Marshal(v) + is.NoErr(err) + + got, err := MarshalVmap(&v) + is.NoErr(err) + + is.Equal(string(expected), string(got)) +} + +func TestMarshalVmapEmptyVastFast(t *testing.T) { + is := is.New(t) + doc, err := os.ReadFile("sample-vmap/testVmapEmptyVast.xml") + is.NoErr(err) + + var v VMAP + err = xml.Unmarshal(doc, &v) + is.NoErr(err) + + expected, err := xml.Marshal(v) + is.NoErr(err) + + got, err := MarshalVmap(&v) + is.NoErr(err) + + is.Equal(string(expected), string(got)) +} + +func TestMarshalVastFast(t *testing.T) { + is := is.New(t) + doc, err := os.ReadFile("sample-vmap/testVast.xml") + is.NoErr(err) + + var v VAST + err = xml.Unmarshal(doc, &v) + is.NoErr(err) + + expected, err := xml.Marshal(v) + is.NoErr(err) + + got, err := MarshalVast(&v) + is.NoErr(err) + + is.Equal(string(expected), string(got)) +} + +func TestMarshalVastEmptyFast(t *testing.T) { + is := is.New(t) + doc, err := os.ReadFile("sample-vmap/testVast3.xml") + is.NoErr(err) + + var v VAST + err = xml.Unmarshal(doc, &v) + is.NoErr(err) + + expected, err := xml.Marshal(v) + is.NoErr(err) + + got, err := MarshalVast(&v) + is.NoErr(err) + + is.Equal(string(expected), string(got)) +} + +func TestMarshalVast2Fast(t *testing.T) { + is := is.New(t) + doc, err := os.ReadFile("sample-vmap/testVast2.xml") + is.NoErr(err) + + var v VAST + err = xml.Unmarshal(doc, &v) + is.NoErr(err) + + expected, err := xml.Marshal(v) + is.NoErr(err) + + got, err := MarshalVast(&v) + is.NoErr(err) + + is.Equal(string(expected), string(got)) +} + +func TestMarshalSpecialCharsFast(t *testing.T) { + is := is.New(t) + doc, err := os.ReadFile("sample-vmap/testVastSpecialChars.xml") + is.NoErr(err) + + var v VAST + err = xml.Unmarshal(doc, &v) + is.NoErr(err) + + expected, err := xml.Marshal(v) + is.NoErr(err) + + got, err := MarshalVast(&v) + is.NoErr(err) + + is.Equal(string(expected), string(got)) +} + +// --- Fast Marshal Benchmarks --- + +func BenchmarkXMLMarshalVmap(b *testing.B) { + doc, err := os.ReadFile("sample-vmap/testVmap.xml") + if err != nil { + b.Fatal(err) + } + var v VMAP + if err := xml.Unmarshal(doc, &v); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = xml.Marshal(v) + } +} + +func BenchmarkFastMarshalVmap(b *testing.B) { + doc, err := os.ReadFile("sample-vmap/testVmap.xml") + if err != nil { + b.Fatal(err) + } + var v VMAP + if err := xml.Unmarshal(doc, &v); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = MarshalVmap(&v) + } +} + +func BenchmarkXMLMarshalVast(b *testing.B) { + doc, err := os.ReadFile("sample-vmap/testVast.xml") + if err != nil { + b.Fatal(err) + } + var v VAST + if err := xml.Unmarshal(doc, &v); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = xml.Marshal(v) + } +} + +func BenchmarkFastMarshalVast(b *testing.B) { + doc, err := os.ReadFile("sample-vmap/testVast.xml") + if err != nil { + b.Fatal(err) + } + var v VAST + if err := xml.Unmarshal(doc, &v); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = MarshalVast(&v) + } +} + func TestDecodeCompliance(t *testing.T) { wg := sync.WaitGroup{} //Check for race conditions