Skip to content

Commit 2fe587c

Browse files
authored
Remove presentation, add layout (#245)
1 parent 7add072 commit 2fe587c

24 files changed

+494
-546
lines changed

pkg/fetcher/fetcher_file.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ func (r *FileResource) File() string {
150150

151151
func (r *FileResource) open() (*os.File, *ResourceError) {
152152
if r.file != nil {
153-
r.file.Seek(0, io.SeekStart)
153+
if _, err := r.file.Seek(0, io.SeekStart); err != nil {
154+
return nil, Other(err)
155+
}
154156
return r.file, nil
155157
}
156158
f, err := os.Open(r.path)
@@ -167,6 +169,7 @@ func (r *FileResource) open() (*os.File, *ResourceError) {
167169
r.file = f
168170
runtime.AddCleanup(r, func(f *os.File) {
169171
f.Close()
172+
r.file = nil
170173
}, f)
171174
return f, nil
172175
}

pkg/manifest/alt_identifier.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package manifest
2+
3+
import (
4+
"encoding/json"
5+
6+
"github.com/pkg/errors"
7+
)
8+
9+
// AltIdentifier
10+
// https://github.com/readium/webpub-manifest/tree/master/contexts/default#identifier
11+
// https://github.com/readium/webpub-manifest/blob/master/schema/altIdentifier.schema.json
12+
type AltIdentifier struct {
13+
Value string `json:"value"`
14+
Scheme string `json:"scheme,omitempty"`
15+
}
16+
17+
// Parses an [AltIdentifier] from its RWPM JSON representation.
18+
// A altIdentifier can be parsed from a single string, or an object.
19+
func AltIdentifierFromJSON(rawJson any) (*AltIdentifier, error) {
20+
if rawJson == nil {
21+
return nil, nil
22+
}
23+
switch rjs := rawJson.(type) {
24+
case string:
25+
return &AltIdentifier{Value: rjs}, nil
26+
case map[string]any:
27+
n := AltIdentifier{
28+
Value: parseOptString(rjs["value"]),
29+
Scheme: parseOptString(rjs["scheme"]),
30+
}
31+
if n.Value == "" {
32+
return nil, errors.New("AltIdentifier must have a non-empty 'value' field")
33+
}
34+
35+
return &n, nil
36+
default:
37+
return nil, errors.New("AltIdentifier has invalid JSON object")
38+
}
39+
}
40+
41+
// Creates a list of [AltIdentifier] from its RWPM JSON representation.
42+
func AltIdentifierFromJSONArray(rawJsonArray any) ([]AltIdentifier, error) {
43+
var altIdentifiers []AltIdentifier
44+
switch rjx := rawJsonArray.(type) {
45+
case []any:
46+
altIdentifiers = make([]AltIdentifier, 0, len(rjx))
47+
for i, entry := range rjx {
48+
ri, err := AltIdentifierFromJSON(entry)
49+
if err != nil {
50+
return nil, errors.Wrapf(err, "failed unmarshalling AltIdentifier at position %d", i)
51+
}
52+
if ri == nil {
53+
continue
54+
}
55+
altIdentifiers = append(altIdentifiers, *ri)
56+
}
57+
default:
58+
i, err := AltIdentifierFromJSON(rjx)
59+
if err != nil {
60+
return nil, err
61+
}
62+
if i != nil {
63+
altIdentifiers = []AltIdentifier{*i}
64+
}
65+
}
66+
return altIdentifiers, nil
67+
}
68+
69+
func (s *AltIdentifier) UnmarshalJSON(data []byte) error {
70+
var object any
71+
err := json.Unmarshal(data, &object)
72+
if err != nil {
73+
return err
74+
}
75+
fs, err := AltIdentifierFromJSON(object)
76+
if err != nil {
77+
return err
78+
}
79+
*s = *fs
80+
return nil
81+
}
82+
83+
func (s AltIdentifier) MarshalJSON() ([]byte, error) {
84+
if s.Scheme == "" {
85+
// If scheme is empty, AltIdentifier can be just a string value
86+
return json.Marshal(s.Value)
87+
}
88+
type alias AltIdentifier // Prevent infinite recursion
89+
return json.Marshal(alias(s))
90+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package manifest
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestAltIdentifierUnmarshalString(t *testing.T) {
11+
ai, err := AltIdentifierFromJSON("https://example.com/alt-id")
12+
require.NoError(t, err)
13+
require.Equal(t, &AltIdentifier{
14+
Value: "https://example.com/alt-id",
15+
}, ai, "parsed JSON string should be equal to string")
16+
}
17+
18+
func TestAltIdentifierUnmarshalMinimalJSON(t *testing.T) {
19+
var ai AltIdentifier
20+
err := ai.UnmarshalJSON([]byte(`{"value":"https://example.com/alt-id"}`))
21+
require.NoError(t, err)
22+
23+
require.Equal(t, &AltIdentifier{
24+
Value: "https://example.com/alt-id",
25+
}, &ai, "parsed JSON object should be equal to AltIdentifier object")
26+
}
27+
28+
func TestAltIdentifierUnmarshalFullJSON(t *testing.T) {
29+
var ai AltIdentifier
30+
err := ai.UnmarshalJSON([]byte(`{
31+
"value": "https://example.com/alt-id",
32+
"scheme": "http://example.com/scheme"
33+
}`))
34+
require.NoError(t, err)
35+
36+
require.Equal(t, &AltIdentifier{
37+
Value: "https://example.com/alt-id",
38+
Scheme: "http://example.com/scheme",
39+
}, &ai, "parsed JSON object should be equal to AltIdentifier object")
40+
}
41+
42+
func TestAltIdentifierUnmarshalNilJSON(t *testing.T) {
43+
ai, err := AltIdentifierFromJSON(nil)
44+
require.NoError(t, err)
45+
require.Nil(t, ai, "should return nil for nil JSON input")
46+
}
47+
48+
func TestAltIdentifierRequiresValue(t *testing.T) {
49+
var ai AltIdentifier
50+
require.Error(t, json.Unmarshal([]byte(`{"scheme":"http://example.com/scheme"}`), &ai), "value is required for AltIdentifier objects")
51+
}
52+
53+
func TestAltIdentifierFromJSONArray(t *testing.T) {
54+
var ais []AltIdentifier
55+
err := json.Unmarshal([]byte(`["id1", {"value":"id2", "scheme":"http://example.com/scheme"}]`), &ais)
56+
require.NoError(t, err)
57+
require.Len(t, ais, 2)
58+
require.Equal(t, []AltIdentifier{
59+
{Value: "id1"},
60+
{Value: "id2", Scheme: "http://example.com/scheme"},
61+
}, ais, "parsed JSON array should match expected AltIdentifier objects")
62+
}
63+
64+
func TestAltIdentifierUnmarshalNilJSONArray(t *testing.T) {
65+
ais, err := AltIdentifierFromJSONArray(nil)
66+
require.NoError(t, err)
67+
require.Empty(t, ais)
68+
}
69+
70+
func TestAltIdentifierUnmarshalJSONArrayString(t *testing.T) {
71+
ais, err := AltIdentifierFromJSONArray("https://example.com/alt-id")
72+
require.NoError(t, err)
73+
require.Len(t, ais, 1)
74+
require.Equal(t, []AltIdentifier{{Value: "https://example.com/alt-id"}}, ais, "parsed JSON string should be converted to single AltIdentifier")
75+
}
76+
77+
func TestAltIdentifierMinimalJSON(t *testing.T) {
78+
bin, err := json.Marshal(AltIdentifier{Value: "https://example.com/alt-id"})
79+
require.NoError(t, err)
80+
require.JSONEq(t, string(bin), `"https://example.com/alt-id"`)
81+
}
82+
83+
func TestAltIdentifierFullJSON(t *testing.T) {
84+
bin, err := json.Marshal(AltIdentifier{
85+
Value: "https://example.com/alt-id",
86+
Scheme: "http://example.com/scheme",
87+
})
88+
require.NoError(t, err)
89+
require.JSONEq(t, string(bin), `{"value":"https://example.com/alt-id", "scheme":"http://example.com/scheme"}`)
90+
}
91+
92+
func TestAltIdentifierJSONArray(t *testing.T) {
93+
bin, err := json.Marshal([]AltIdentifier{
94+
{Value: "https://example.com/alt-id1"},
95+
{Value: "https://example.com/alt-id2", Scheme: "http://example.com/scheme"},
96+
})
97+
require.NoError(t, err)
98+
require.JSONEq(t, string(bin), `[
99+
"https://example.com/alt-id1",
100+
{"value":"https://example.com/alt-id2", "scheme":"http://example.com/scheme"}
101+
]`)
102+
}

pkg/manifest/contributor.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import (
1111
// https://github.com/readium/webpub-manifest/tree/master/contexts/default#contributors
1212
// https://github.com/readium/webpub-manifest/blob/master/schema/contributor-object.schema.json
1313
type Contributor struct {
14-
LocalizedName LocalizedString `json:"name" validate:"required"` // The name of the contributor.
15-
LocalizedSortAs *LocalizedString `json:"sortAs,omitempty"` // The string used to sort the name of the contributor.
16-
Identifier string `json:"identifier,omitempty"` // An unambiguous reference to this contributor.
17-
Roles Strings `json:"role,omitempty"` // The roles of the contributor in the making of the publication.
18-
Position *float64 `json:"position,omitempty"` // The position of the publication in this collection/series, when the contributor represents a collection. TODO validator
19-
Links LinkList `json:"links,omitempty"` // Used to retrieve similar publications for the given contributor.
14+
LocalizedName LocalizedString `json:"name"` // The name of the contributor.
15+
LocalizedSortAs *LocalizedString `json:"sortAs,omitempty"` // The string used to sort the name of the contributor.
16+
Identifier string `json:"identifier,omitempty"` // An unambiguous reference to this contributor.
17+
AltIdentifier []AltIdentifier `json:"altIdentifier,omitempty"` // Alternate identifiers
18+
Roles Strings `json:"role,omitempty"` // The roles of the contributor in the making of the publication.
19+
Position *float64 `json:"position,omitempty"` // The position of the publication in this collection/series, when the contributor represents a collection. TODO validator
20+
Links LinkList `json:"links,omitempty"` // Used to retrieve similar publications for the given contributor.
2021
}
2122

2223
func (c Contributor) Name() string {
@@ -85,6 +86,16 @@ func ContributorFromJSON(rawJson interface{}) (*Contributor, error) {
8586
// Identifier
8687
c.Identifier = parseOptString(dd["identifier"])
8788

89+
// Alt Identifiers
90+
rawAltIdentifiers, ok := dd["altIdentifier"]
91+
if ok {
92+
altIdentifiers, err := AltIdentifierFromJSONArray(rawAltIdentifiers)
93+
if err != nil {
94+
return nil, errors.Wrap(err, "failed parsing Contributor 'altIdentifier'")
95+
}
96+
c.AltIdentifier = altIdentifiers
97+
}
98+
8899
// Position
89100
position, ok := dd["position"].(float64)
90101
if ok { // Need to do this because default is not 0, but nil
@@ -125,7 +136,7 @@ func ContributorFromJSONArray(rawJsonArray interface{}) ([]Contributor, error) {
125136
}
126137

127138
func (c Contributor) MarshalJSON() ([]byte, error) {
128-
if c.LocalizedSortAs == nil && c.Identifier == "" && len(c.Roles) == 0 && c.Position == nil && c.Links == nil && len(c.LocalizedName.Translations) == 1 {
139+
if c.LocalizedSortAs == nil && c.Identifier == "" && len(c.AltIdentifier) == 0 && len(c.Roles) == 0 && c.Position == nil && c.Links == nil && len(c.LocalizedName.Translations) == 1 {
129140
// If everything but name is empty, and there's just one name, Contributor can be just a name
130141
return json.Marshal(c.LocalizedName)
131142
}

pkg/manifest/contributor_test.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ func TestContributorUnmarshalFullJSON(t *testing.T) {
3232
LocalizedName: NewLocalizedStringFromString("Colin Greenwood"),
3333
LocalizedSortAs: &sortAs,
3434
Identifier: "colin",
35-
Roles: []string{"bassist"},
36-
Position: &position,
35+
AltIdentifier: []AltIdentifier{
36+
{Value: "id123", Scheme: "scheme456"},
37+
},
38+
Roles: []string{"bassist"},
39+
Position: &position,
3740
Links: []Link{
3841
{
3942
Href: MustNewHREFFromString("http://link1", false),
@@ -47,6 +50,7 @@ func TestContributorUnmarshalFullJSON(t *testing.T) {
4750
assert.NoError(t, json.Unmarshal([]byte(`{
4851
"name": "Colin Greenwood",
4952
"identifier": "colin",
53+
"altIdentifier": [{"value": "id123", "scheme": "scheme456"}],
5054
"sortAs": "greenwood",
5155
"role": "bassist",
5256
"position": 4,
@@ -116,8 +120,11 @@ func TestContributorFullJSON(t *testing.T) {
116120
LocalizedName: NewLocalizedStringFromString("Colin Greenwood"),
117121
LocalizedSortAs: &sortAs,
118122
Identifier: "colin",
119-
Roles: []string{"bassist"},
120-
Position: &pos,
123+
AltIdentifier: []AltIdentifier{
124+
{Value: "id123", Scheme: "scheme456"},
125+
},
126+
Roles: []string{"bassist"},
127+
Position: &pos,
121128
Links: []Link{
122129
{
123130
Href: MustNewHREFFromString("http://link1", true),
@@ -132,6 +139,7 @@ func TestContributorFullJSON(t *testing.T) {
132139
assert.JSONEq(t, `{
133140
"name": "Colin Greenwood",
134141
"identifier": "colin",
142+
"altIdentifier": [{"value": "id123", "scheme": "scheme456"}],
135143
"sortAs": "greenwood",
136144
"role": "bassist",
137145
"position": 4.0,

pkg/manifest/layout.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package manifest
2+
3+
import "slices"
4+
5+
type Layout string
6+
7+
const (
8+
LayoutNone Layout = "" // No layout specified, reading systems should use their default layout.
9+
LayoutReflowable Layout = "reflowable" // Reading systems are free to adapt text and layout entirely based on user preferences.
10+
LayoutFixed Layout = "fixed" // Each resource is a "page" where both dimensions are usually contained in the device's viewport. Based on user preferences, the reading system may also display two resources side by side in a spread.
11+
LayoutScrolled Layout = "scrolled" // Resources are displayed in a continuous scroll, usually by filling the width of the viewport, without any visible gap between between spine items.
12+
)
13+
14+
// Correct the layout value based on the provided profiles.
15+
func (l Layout) correct(profiles Profiles) Layout {
16+
if len(profiles) == 0 {
17+
return l
18+
}
19+
20+
// Make sure layout has a valid value, otherwise ignore it
21+
switch l {
22+
case LayoutNone, LayoutReflowable, LayoutFixed, LayoutScrolled:
23+
default:
24+
return LayoutNone
25+
}
26+
27+
if slices.ContainsFunc(profiles, func(p Profile) bool {
28+
return p == ProfilePDF || p == ProfileAudiobook
29+
}) {
30+
// For the audiobook and PDF profiles, we ignore the layout
31+
return LayoutNone
32+
}
33+
34+
if slices.Contains(profiles, ProfileDivina) && l == LayoutReflowable {
35+
// We ignore the value if layout is set to reflowable on a Divina
36+
return LayoutNone
37+
}
38+
39+
return l
40+
}
41+
42+
// Determines the actual layout value based on the provided profiles.
43+
func (l Layout) EffectiveValue(profiles Profiles) Layout {
44+
l = l.correct(profiles)
45+
46+
if l == LayoutNone {
47+
// Divina profile defaults to fixed if layout is not present
48+
if slices.Contains(profiles, ProfileDivina) {
49+
return LayoutFixed
50+
}
51+
52+
// EPUB profile defaults to reflowable if layout is not present
53+
if slices.Contains(profiles, ProfileEPUB) {
54+
return LayoutReflowable
55+
}
56+
}
57+
58+
return l
59+
}
60+
61+
// Determines the minimal layout value based on the provided profiles.
62+
func (l Layout) minimalValue(profiles Profiles) Layout {
63+
l = l.correct(profiles)
64+
65+
// Divina profile defaults to fixed if layout is not present
66+
if slices.Contains(profiles, ProfileDivina) && l == LayoutFixed {
67+
return LayoutNone
68+
}
69+
70+
// EPUB profile defaults to reflowable if layout is not present
71+
if slices.Contains(profiles, ProfileEPUB) && l == LayoutReflowable {
72+
return LayoutNone
73+
}
74+
75+
return l
76+
}

pkg/manifest/layout_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package manifest

0 commit comments

Comments
 (0)