Files
comicverse/editor/epub/opf.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
}
}