diff --git a/editor/epub/epub.go b/editor/epub/epub.go new file mode 100644 index 0000000..d73c287 --- /dev/null +++ b/editor/epub/epub.go @@ -0,0 +1,45 @@ +package epub + +import ( + "encoding/xml" + "fmt" +) + +type Meta struct { + Attributes map[string]string `xml:"-"` + Value string `xml:",chardata"` +} + +var ( + _ xml.Marshaler = Meta{} + _ xml.Unmarshaler = (*Meta)(nil) +) + +func (m Meta) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + for n, v := range m.Attributes { + start.Attr = append(start.Attr, xml.Attr{ + Name: xml.Name{Local: n}, + Value: v, + }) + } + return e.EncodeElement(m.Value, start) +} + +func (m *Meta) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if m == nil { + m = &Meta{} + } + if m.Attributes == nil { + m.Attributes = map[string]string{} + } + + for _, attr := range start.Attr { + m.Attributes[attr.Name.Local] = attr.Value + } + + if err := d.DecodeElement(&m.Value, &start); err != nil { + return fmt.Errorf("epub.Meta: failed to decode chardata: %w", err) + } + + return nil +} diff --git a/editor/epub/opf.go b/editor/epub/opf.go new file mode 100644 index 0000000..3b23e32 --- /dev/null +++ b/editor/epub/opf.go @@ -0,0 +1,450 @@ +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 + } +}