Skip to content

Commit 773a25d

Browse files
committed
Initial release: fixed-width line marshal/unmarshal library
Provides Marshal and Unmarshal for fixed-width 80-char line formats (Nets AvtaleGiro, OCR Giro, Bankgirot AutoGiro) using struct tags. - ocr struct tags with position, alignment, and padding options - Marshaler/Unmarshaler interfaces for custom types - Filler interface for gap fill control - Embedded struct support (value and pointer) - Tag overlap detection - Per-type metadata caching (sync.Map, same pattern as encoding/json) - 96%+ test coverage
0 parents  commit 773a25d

File tree

7 files changed

+2613
-0
lines changed

7 files changed

+2613
-0
lines changed

.github/workflows/ci.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
name: Test
15+
runs-on: ubuntu-latest
16+
strategy:
17+
matrix:
18+
go-version: ["1.22", "1.23", "1.24", "1.25", "1.26"]
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Set up Go
23+
uses: actions/setup-go@v5
24+
with:
25+
go-version: ${{ matrix.go-version }}
26+
27+
- name: Build
28+
run: go build ./...
29+
30+
- name: Test
31+
run: go test -v ./...
32+
33+
coverage:
34+
name: Coverage
35+
runs-on: ubuntu-latest
36+
steps:
37+
- uses: actions/checkout@v4
38+
39+
- name: Set up Go
40+
uses: actions/setup-go@v5
41+
with:
42+
go-version: "1.26"
43+
44+
- name: Test with coverage
45+
run: go test -coverprofile=coverage.out -covermode=atomic ./...
46+
47+
- name: Upload coverage to Codecov
48+
uses: codecov/codecov-action@v4
49+
with:
50+
files: coverage.out
51+
fail_ci_if_error: false
52+
env:
53+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
54+
55+
lint:
56+
name: Lint
57+
runs-on: ubuntu-latest
58+
steps:
59+
- uses: actions/checkout@v4
60+
61+
- name: Set up Go
62+
uses: actions/setup-go@v5
63+
with:
64+
go-version: "1.26"
65+
66+
- name: golangci-lint
67+
uses: golangci/golangci-lint-action@v6
68+
with:
69+
version: latest

README.md

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# ocrline
2+
3+
[![CI](https://github.com/karolusz/ocrline/actions/workflows/ci.yml/badge.svg)](https://github.com/karolusz/ocrline/actions/workflows/ci.yml)
4+
[![codecov](https://codecov.io/gh/karolusz/ocrline/branch/main/graph/badge.svg)](https://codecov.io/gh/karolusz/ocrline)
5+
[![Go Reference](https://pkg.go.dev/badge/github.com/karolusz/ocrline.svg)](https://pkg.go.dev/github.com/karolusz/ocrline)
6+
[![Go Report Card](https://goreportcard.com/badge/github.com/karolusz/ocrline)](https://goreportcard.com/report/github.com/karolusz/ocrline)
7+
8+
Marshal and unmarshal Go structs to and from fixed-width line formats using struct tags.
9+
10+
Built for Scandinavian payment file formats (Nets AvtaleGiro, OCR Giro, Bankgirot AutoGiro) but works with any 80-character (or custom width) fixed-position record format.
11+
12+
## Install
13+
14+
```
15+
go get github.com/karolusz/ocrline
16+
```
17+
18+
## Usage
19+
20+
Define structs with `ocr` tags specifying 0-based byte positions (Go slice convention):
21+
22+
```go
23+
type TransmissionStart struct {
24+
FormatCode string `ocr:"0:2"`
25+
ServiceCode string `ocr:"2:4"`
26+
TransactionType string `ocr:"4:6"`
27+
RecordType int `ocr:"6:8"`
28+
DataTransmitter string `ocr:"8:16"`
29+
TransmissionNo string `ocr:"16:23"`
30+
DataRecipient string `ocr:"23:31"`
31+
}
32+
```
33+
34+
### Unmarshal
35+
36+
```go
37+
line := "NY000010555555551000081000080800000000000000000000000000000000000000000000000000"
38+
39+
var record TransmissionStart
40+
if err := ocrline.Unmarshal(line, &record); err != nil {
41+
log.Fatal(err)
42+
}
43+
44+
fmt.Println(record.FormatCode) // "NY"
45+
fmt.Println(record.DataTransmitter) // "55555555"
46+
fmt.Println(record.RecordType) // 10
47+
```
48+
49+
### Marshal
50+
51+
```go
52+
record := TransmissionStart{
53+
FormatCode: "NY",
54+
ServiceCode: "00",
55+
TransactionType: "00",
56+
RecordType: 10,
57+
DataTransmitter: "55555555",
58+
TransmissionNo: "1000081",
59+
DataRecipient: "00008080",
60+
}
61+
62+
line, err := ocrline.Marshal(record)
63+
// "NY000010555555551000081000080800000000000000000000000000000000000000000000000000"
64+
```
65+
66+
## Tag Syntax
67+
68+
```
69+
ocr:"start:end"
70+
ocr:"start:end,option,option,..."
71+
```
72+
73+
Positions are **0-based, exclusive end** (like Go slices). `ocr:"0:2"` reads `line[0:2]`.
74+
75+
### Options
76+
77+
| Option | Description |
78+
|--------|-------------|
79+
| `align-left` | Left-align the value in the field |
80+
| `align-right` | Right-align the value in the field |
81+
| `pad-zero` | Pad with `'0'` characters |
82+
| `pad-space` | Pad with `' '` characters |
83+
| `omitempty` | If zero-valued, fill with padding instead of the value |
84+
85+
### Type Defaults
86+
87+
| Go Type | Default Alignment | Default Padding |
88+
|---------|-------------------|-----------------|
89+
| `string` | left | space |
90+
| `int`, `int8`..`int64` | right | zero |
91+
| `uint`, `uint8`..`uint64` | right | zero |
92+
| `bool` | right | zero |
93+
| `*T` | inherits from `T` | inherits from `T` |
94+
95+
Named types follow their underlying type: `type Numeric string` gets string defaults.
96+
97+
## Struct Composition
98+
99+
Embedded structs are flattened, just like `encoding/json`:
100+
101+
```go
102+
type RecordBase struct {
103+
FormatCode string `ocr:"0:2"`
104+
ServiceCode string `ocr:"2:4"`
105+
RecordType int `ocr:"6:8"`
106+
}
107+
108+
type PaymentRecord struct {
109+
RecordBase
110+
PayerNumber string `ocr:"15:31"`
111+
Amount int `ocr:"31:43"`
112+
}
113+
```
114+
115+
Embedded pointer structs (`*RecordBase`) are also supported. On unmarshal, nil pointers are auto-allocated. On marshal, nil embedded pointers are skipped (gaps filled with default).
116+
117+
## Gap Filling
118+
119+
Byte positions not covered by any field are filled with `'0'` by default. To fill specific gaps with a different character, implement the `Filler` interface:
120+
121+
```go
122+
func (r PaymentRecord) OCRFill() []ocrline.Fill {
123+
return []ocrline.Fill{
124+
{Start: 8, End: 15, Char: ' '}, // positions 8-14 filled with spaces
125+
}
126+
}
127+
```
128+
129+
## Custom Types
130+
131+
Implement `Marshaler` and `Unmarshaler` for full control over field serialization:
132+
133+
```go
134+
type ServiceCode string
135+
136+
func (s ServiceCode) MarshalOCR() (string, error) {
137+
return string(s), nil
138+
}
139+
140+
func (s *ServiceCode) UnmarshalOCR(data string) error {
141+
*s = ServiceCode(strings.TrimSpace(data))
142+
return nil
143+
}
144+
```
145+
146+
## Line Width
147+
148+
`Marshal` outputs 80 characters by default. Use `MarshalWidth` for other widths:
149+
150+
```go
151+
line, err := ocrline.MarshalWidth(record, 120) // 120-char line
152+
line, err := ocrline.MarshalWidth(record, 0) // no padding, exact width of rightmost field
153+
```
154+
155+
## Validation and Caching
156+
157+
Struct metadata is parsed, validated, and cached per type on first use (same pattern as `encoding/json`). Subsequent calls for the same struct type have zero reflection overhead for tag parsing.
158+
159+
Validated once per type:
160+
161+
- **Tag syntax** - start and end must be integers, `start >= 0`, `end > start`
162+
- **Overlapping fields** - two fields covering the same positions are rejected with `*OverlapError`
163+
- **Invalid tags** - malformed `ocr` tags produce `*TagError`
164+
165+
Validated per call:
166+
167+
- **Out-of-range fields** - on unmarshal, fields exceeding the line length produce `*UnmarshalRangeError`
168+
- **Unexported fields** are silently skipped (same as `encoding/json`)
169+
170+
## API
171+
172+
```go
173+
func Marshal(v any) (string, error)
174+
func MarshalWidth(v any, width int) (string, error)
175+
func Unmarshal(line string, v any) error
176+
```
177+
178+
### Interfaces
179+
180+
```go
181+
type Marshaler interface {
182+
MarshalOCR() (string, error)
183+
}
184+
185+
type Unmarshaler interface {
186+
UnmarshalOCR(data string) error
187+
}
188+
189+
type Filler interface {
190+
OCRFill() []Fill
191+
}
192+
```
193+
194+
## Supported Formats
195+
196+
The library is format-agnostic. It has been designed with these formats in mind:
197+
198+
| Format | Country | Provider | Line Width |
199+
|--------|---------|----------|------------|
200+
| AvtaleGiro | Norway | Nets | 80 |
201+
| OCR Giro | Norway | Nets | 80 |
202+
| AutoGiro | Sweden | Bankgirot | 80 |
203+
204+
## License
205+
206+
MIT

errors.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package ocrline
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
)
7+
8+
// OverlapError describes two fields whose ocr tag ranges overlap.
9+
type OverlapError struct {
10+
Field1 string
11+
Start1, End1 int
12+
Field2 string
13+
Start2, End2 int
14+
}
15+
16+
func (e *OverlapError) Error() string {
17+
return fmt.Sprintf(
18+
"ocrline: fields %s [%d:%d] and %s [%d:%d] overlap",
19+
e.Field1, e.Start1, e.End1,
20+
e.Field2, e.Start2, e.End2,
21+
)
22+
}
23+
24+
// InvalidMarshalError describes an invalid argument passed to Marshal.
25+
type InvalidMarshalError struct {
26+
Type reflect.Type
27+
}
28+
29+
func (e *InvalidMarshalError) Error() string {
30+
if e.Type == nil {
31+
return "ocrline: Marshal(nil)"
32+
}
33+
if e.Type.Kind() == reflect.Pointer {
34+
return "ocrline: Marshal(nil " + e.Type.String() + ")"
35+
}
36+
return "ocrline: Marshal(non-struct " + e.Type.String() + ")"
37+
}
38+
39+
// InvalidUnmarshalError describes an invalid argument passed to Unmarshal.
40+
type InvalidUnmarshalError struct {
41+
Type reflect.Type
42+
}
43+
44+
func (e *InvalidUnmarshalError) Error() string {
45+
if e.Type == nil {
46+
return "ocrline: Unmarshal(nil)"
47+
}
48+
if e.Type.Kind() != reflect.Pointer {
49+
return "ocrline: Unmarshal(non-pointer " + e.Type.String() + ")"
50+
}
51+
return "ocrline: Unmarshal(nil " + e.Type.String() + ")"
52+
}
53+
54+
// TagError describes an error in an ocr struct tag.
55+
type TagError struct {
56+
Field string
57+
Tag string
58+
Err error
59+
}
60+
61+
func (e *TagError) Error() string {
62+
return fmt.Sprintf("ocrline: invalid tag on field %s: %q: %v", e.Field, e.Tag, e.Err)
63+
}
64+
65+
func (e *TagError) Unwrap() error {
66+
return e.Err
67+
}
68+
69+
// MarshalFieldError describes an error marshalling a specific field.
70+
type MarshalFieldError struct {
71+
Field string
72+
Err error
73+
}
74+
75+
func (e *MarshalFieldError) Error() string {
76+
return fmt.Sprintf("ocrline: error marshalling field %s: %v", e.Field, e.Err)
77+
}
78+
79+
func (e *MarshalFieldError) Unwrap() error {
80+
return e.Err
81+
}
82+
83+
// UnmarshalFieldError describes an error unmarshalling a specific field.
84+
type UnmarshalFieldError struct {
85+
Field string
86+
Err error
87+
}
88+
89+
func (e *UnmarshalFieldError) Error() string {
90+
return fmt.Sprintf("ocrline: error unmarshalling field %s: %v", e.Field, e.Err)
91+
}
92+
93+
func (e *UnmarshalFieldError) Unwrap() error {
94+
return e.Err
95+
}
96+
97+
// UnmarshalRangeError describes a field whose ocr tag range exceeds the input line.
98+
type UnmarshalRangeError struct {
99+
Field string
100+
Start int
101+
End int
102+
LineWidth int
103+
}
104+
105+
func (e *UnmarshalRangeError) Error() string {
106+
return fmt.Sprintf(
107+
"ocrline: field %s range [%d:%d] exceeds line width %d",
108+
e.Field, e.Start, e.End, e.LineWidth,
109+
)
110+
}

0 commit comments

Comments
 (0)