451 lines
11 KiB
Go
451 lines
11 KiB
Go
package epub
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
type Package struct {
|
|
Metadata Metadata `xml:"metadata"`
|
|
Manifest Manisfest `xml:"manifest"`
|
|
Spine Spine `xml:"spine"`
|
|
|
|
// TODO: Collections https://www.w3.org/TR/epub-33/#sec-pkg-collections
|
|
}
|
|
|
|
var _ xml.Marshaler = Package{}
|
|
|
|
func (p Package) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
|
start.Name = xml.Name{
|
|
Local: "package",
|
|
Space: "http://www.idpf.org/2007/opf",
|
|
}
|
|
|
|
start.Attr = append(start.Attr, []xml.Attr{
|
|
{Name: xml.Name{Local: "xmlns:dc"}, Value: "http://purl.org/dc/elements/1.1/"},
|
|
{Name: xml.Name{Local: "xmlns:dcterms"}, Value: "http://purl.org/dc/terms/"},
|
|
{Name: xml.Name{Local: "xmlns:opf"}, Value: "http://www.idpf.org/2007/opf"},
|
|
{Name: xml.Name{Local: "unique-identifier"}, Value: uniqueIdentifierID},
|
|
{Name: xml.Name{Local: "version"}, Value: "3.0"},
|
|
}...)
|
|
|
|
if err := e.EncodeToken(start); err != nil {
|
|
return err
|
|
}
|
|
|
|
err := e.EncodeElement(p.Metadata, xml.StartElement{Name: xml.Name{Local: "metadata"}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = e.EncodeElement(p.Manifest, xml.StartElement{Name: xml.Name{Local: "manifest"}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = e.EncodeElement(p.Spine, xml.StartElement{Name: xml.Name{Local: "spine"}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.EncodeToken(xml.EndElement{Name: start.Name})
|
|
}
|
|
|
|
type Metadata struct {
|
|
ID string `xml:"dc:identifier"`
|
|
Title string `xml:"dc:title"`
|
|
Language language.Tag `xml:"dc:language"`
|
|
Creators []Person `xml:"dc:creator"`
|
|
Contributors []Person `xml:"dc:contributor"`
|
|
Date time.Time `xml:"dc:date"`
|
|
Modified time.Time `xml:"-"`
|
|
|
|
// TODO: Support for dc:subject, dc:type and meta elements
|
|
// https://www.w3.org/TR/epub-33/#sec-opf-dcsubject
|
|
}
|
|
|
|
var (
|
|
_ xml.Marshaler = Metadata{}
|
|
_ xml.Unmarshaler = (*Metadata)(nil)
|
|
)
|
|
|
|
func (m Metadata) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
|
if err := e.EncodeToken(start); err != nil {
|
|
return err
|
|
}
|
|
|
|
helper := encoderHelper(e, "epub.Metadata")
|
|
|
|
err := helper("dc:identifier", m.ID, xml.Attr{
|
|
Name: xml.Name{Local: "id"}, Value: uniqueIdentifierID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = helper("dc:title", m.Title); err != nil {
|
|
return err
|
|
}
|
|
if err = helper("dc:language", m.Language.String()); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, creator := range m.Creators {
|
|
err := creator.marshalIntoRootXML(xml.Name{Local: "dc:creator"}, e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, contributor := range m.Contributors {
|
|
err := contributor.marshalIntoRootXML(xml.Name{Local: "dc:contributor"}, e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !m.Date.IsZero() {
|
|
if err = helper("dc:date", m.Date.Format(time.RFC3339)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !m.Modified.IsZero() {
|
|
if err = helper("meta", m.Modified.Format(time.RFC3339), xml.Attr{
|
|
Name: xml.Name{Local: "property"}, Value: "dcterms:modified",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return e.EncodeToken(start.End())
|
|
}
|
|
|
|
func (m *Metadata) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|
if m == nil {
|
|
m = &Metadata{}
|
|
}
|
|
|
|
var v struct {
|
|
ID string `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
|
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
|
Language language.Tag `xml:"http://purl.org/dc/elements/1.1/ language"`
|
|
Creators []Person `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
|
Contributors []Person `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
|
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
|
|
|
Meta []Meta `xml:"meta"`
|
|
}
|
|
|
|
if err := d.DecodeElement(&v, &start); err != nil {
|
|
return fmt.Errorf("epub.Metadata: unable to unmarshal: %w", err)
|
|
}
|
|
|
|
m.ID = v.ID
|
|
m.Title = v.Title
|
|
m.Language = v.Language
|
|
|
|
if v.Date != "" {
|
|
t, err := time.Parse(time.RFC3339, v.Date)
|
|
if err != nil {
|
|
return fmt.Errorf("epub.Metadata: date is not valid: %w", err)
|
|
}
|
|
m.Date = t
|
|
}
|
|
|
|
m.Creators = v.Creators
|
|
|
|
for i, c := range m.Creators {
|
|
c, err := c.unmarshalFromMetas(v.Meta)
|
|
if err != nil {
|
|
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
|
|
}
|
|
m.Creators[i] = c
|
|
}
|
|
|
|
m.Contributors = v.Contributors
|
|
|
|
for i, c := range m.Contributors {
|
|
c, err := c.unmarshalFromMetas(v.Meta)
|
|
if err != nil {
|
|
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
|
|
}
|
|
m.Contributors[i] = c
|
|
}
|
|
|
|
for _, meta := range v.Meta {
|
|
if property, ok := meta.Attributes["property"]; ok {
|
|
switch property {
|
|
case "dcterms:modified":
|
|
t, err := time.Parse(time.RFC3339, meta.Value)
|
|
if err != nil {
|
|
return fmt.Errorf("epub.Metadata: modified date is not valid: %w", err)
|
|
}
|
|
m.Modified = t
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var uniqueIdentifierID = "pub-id"
|
|
|
|
type Person struct {
|
|
ID string `xml:"id,attr"`
|
|
Name string `xml:",chardata"`
|
|
Role string `xml:"-"`
|
|
FileAs string `xml:"-"`
|
|
|
|
AlternateScripts map[language.Tag]string `xml:"-"`
|
|
}
|
|
|
|
func (p Person) marshalIntoRootXML(name xml.Name, e *xml.Encoder) error {
|
|
if p.ID == "" {
|
|
p.ID = shortid.New().String()
|
|
}
|
|
|
|
err := e.EncodeElement(p.Name, xml.StartElement{
|
|
Name: name,
|
|
Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: p.ID}},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for lang, name := range p.AlternateScripts {
|
|
err = e.EncodeElement(name, xml.StartElement{
|
|
Name: xml.Name{Local: "meta"},
|
|
Attr: []xml.Attr{
|
|
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
|
{Name: xml.Name{Local: "property"}, Value: "alternate-script"},
|
|
{Name: xml.Name{Local: "xml:lang"}, Value: lang.String()},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if p.FileAs != "" {
|
|
err = e.EncodeElement(p.FileAs, xml.StartElement{
|
|
Name: xml.Name{Local: "meta"},
|
|
Attr: []xml.Attr{
|
|
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
|
{Name: xml.Name{Local: "property"}, Value: "file-as"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if p.Role != "" {
|
|
err = e.EncodeElement(p.Role, xml.StartElement{
|
|
Name: xml.Name{Local: "meta"},
|
|
Attr: []xml.Attr{
|
|
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
|
{Name: xml.Name{Local: "property"}, Value: "role"},
|
|
{Name: xml.Name{Local: "scheme"}, Value: "marc:relators"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p Person) unmarshalFromMetas(metaList []Meta) (Person, error) {
|
|
if p.ID == "" {
|
|
return p, nil
|
|
}
|
|
if p.AlternateScripts == nil {
|
|
p.AlternateScripts = map[language.Tag]string{}
|
|
}
|
|
|
|
for _, meta := range metaList {
|
|
refines, ok := meta.Attributes["refines"]
|
|
if !ok || refines != fmt.Sprintf("#%s", p.ID) {
|
|
continue
|
|
}
|
|
|
|
property, ok := meta.Attributes["property"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
switch property {
|
|
case "alternate-script":
|
|
l, ok := meta.Attributes["lang"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
lang, err := language.Parse(l)
|
|
if err != nil {
|
|
return p, fmt.Errorf("epub.Person: language %q is not valid: %w", l, err)
|
|
}
|
|
p.AlternateScripts[lang] = meta.Value
|
|
case "file-as":
|
|
p.FileAs = meta.Value
|
|
case "role":
|
|
p.Role = meta.Value
|
|
}
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
type Manisfest struct {
|
|
Items []Item `xml:"item"`
|
|
}
|
|
|
|
type Item struct {
|
|
ID string `xml:"id,attr"`
|
|
HRef string `xml:"href,attr"`
|
|
MediaType string `xml:"media-type,attr"`
|
|
MediaOverlay string `xml:"media-overlay,attr,omitempty"`
|
|
Properties ItemProperties `xml:"properties,attr,omitempty"`
|
|
}
|
|
|
|
type Spine struct {
|
|
ID string `xml:"id,attr,omitempty"`
|
|
Toc string `xml:"toc,attr,omitempty"`
|
|
|
|
PageProgressionDir PageProgressionDir `xml:"page-progression-direction,attr,omitempty"`
|
|
|
|
ItemRefs []ItemRef `xml:"itemref"`
|
|
}
|
|
|
|
type PageProgressionDir string
|
|
|
|
const (
|
|
PageProgressionDirDefault PageProgressionDir = "default"
|
|
PageProgressionDirLTR PageProgressionDir = "ltr"
|
|
PageProgressionDirRTL PageProgressionDir = "rtl"
|
|
)
|
|
|
|
type ItemRef struct {
|
|
IDRef string `xml:"idref,attr"`
|
|
ID string `xml:"id,attr"`
|
|
NotLinear bool `xml:"linear,attr"`
|
|
Properties ItemProperties `xml:"properties,attr"`
|
|
}
|
|
|
|
var (
|
|
_ xml.Marshaler = ItemRef{}
|
|
_ xml.Unmarshaler = (*ItemRef)(nil)
|
|
)
|
|
|
|
func (ref ItemRef) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
|
linear := xml.Attr{Name: xml.Name{Local: "linear"}}
|
|
if !ref.NotLinear {
|
|
linear.Value = "no"
|
|
} else {
|
|
linear.Value = "yes"
|
|
}
|
|
|
|
props, _ := ref.Properties.MarshalXMLAttr(xml.Name{Local: "properties"})
|
|
|
|
start.Attr = append(start.Attr, []xml.Attr{
|
|
{Name: xml.Name{Local: "idref"}, Value: ref.IDRef},
|
|
{Name: xml.Name{Local: "id"}, Value: ref.ID},
|
|
linear,
|
|
props,
|
|
}...)
|
|
|
|
if err := e.EncodeToken(start); err != nil {
|
|
return err
|
|
}
|
|
return e.EncodeToken(start.End())
|
|
}
|
|
|
|
func (ref *ItemRef) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|
if ref == nil {
|
|
ref = &ItemRef{}
|
|
}
|
|
for _, attr := range start.Attr {
|
|
switch attr.Name.Local {
|
|
case "idref":
|
|
ref.IDRef = attr.Value
|
|
case "id":
|
|
ref.ID = attr.Value
|
|
case "linear":
|
|
if attr.Value == "no" {
|
|
ref.NotLinear = true
|
|
} else {
|
|
ref.NotLinear = false
|
|
}
|
|
case "properties":
|
|
ref.Properties.UnmarshalXMLAttr(attr)
|
|
}
|
|
}
|
|
var t string
|
|
return d.DecodeElement(&t, &start)
|
|
}
|
|
|
|
type (
|
|
ItemProperty string
|
|
ItemProperties []ItemProperty
|
|
)
|
|
|
|
const (
|
|
ItemPropertyCoverImage ItemProperty = "cover-image"
|
|
ItemPropertyNav ItemProperty = "nav"
|
|
ItemPropertyMathML ItemProperty = "mathml"
|
|
ItemPropertyRemoteResources ItemProperty = "remote-resources"
|
|
ItemPropertyScripted ItemProperty = "scripted"
|
|
ItemPropertySVG ItemProperty = "svg"
|
|
ItemPropertySwitch ItemProperty = "switch"
|
|
)
|
|
|
|
var (
|
|
_ xml.MarshalerAttr = (ItemProperties)(nil)
|
|
_ xml.UnmarshalerAttr = (*ItemProperties)(nil)
|
|
)
|
|
|
|
func (is ItemProperties) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
|
|
strs := make([]string, len(is))
|
|
for i := range is {
|
|
strs[i] = string(is[i])
|
|
}
|
|
return xml.Attr{Name: name, Value: strings.Join(strs, " ")}, nil
|
|
}
|
|
|
|
func (is *ItemProperties) UnmarshalXMLAttr(attr xml.Attr) error {
|
|
if is == nil {
|
|
is = &ItemProperties{}
|
|
}
|
|
for s := range strings.SplitSeq(attr.Value, " ") {
|
|
*is = append(*is, ItemProperty(s))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func encoderHelper(e *xml.Encoder, errPrefix ...string) func(
|
|
key string, value string, attrs ...xml.Attr,
|
|
) error {
|
|
if len(errPrefix) == 0 {
|
|
errPrefix[0] = ""
|
|
} else {
|
|
errPrefix[0] = fmt.Sprintf("%s: ", errPrefix[0])
|
|
}
|
|
|
|
return func(key string, value string, attrs ...xml.Attr) error {
|
|
err := e.EncodeElement(value, xml.StartElement{
|
|
Name: xml.Name{Local: key},
|
|
Attr: attrs,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("%sfailed to encode %q: %w", errPrefix[0], key, err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|