Files
comicInfoXMLConv/comicinfoxml.py
2026-06-28 17:22:25 +09:00

409 lines
15 KiB
Python

"""A class to encapsulate ComicRack's ComicInfo.xml data."""
# Copyright 2012-2014 ComicTagger Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from typing import Any
from typing import TYPE_CHECKING
from comicapi import utils
from comicapi.genericmetadata import FileHash
from comicapi.genericmetadata import GenericMetadata
from comicapi.genericmetadata import PageMetadata
from comicapi.tags import Tag
if TYPE_CHECKING:
from comicapi.archivers import Archiver
logger = logging.getLogger(f'comicapi.metadata.{__name__}')
class ComicInfoXml(Tag):
enabled = True
id = 'cix'
def __init__(self, version: str) -> None:
super().__init__(version)
self.file = 'ComicInfo.xml'
self.supported_attributes = {
'original_hash',
'series',
'issue',
'issue_count',
'title',
'gtin',
'volume',
'genres',
'description',
'notes',
'alternate_series',
'alternate_number',
'alternate_count',
'story_arcs',
'series_groups',
'publisher',
'imprint',
'day',
'month',
'year',
'language',
'web_links',
'format',
'manga',
'black_and_white',
'maturity_rating',
'critical_rating',
'scan_info',
'tags',
'pages',
'pages.bookmark',
'pages.double_page',
'pages.height',
'pages.image_index',
'pages.size',
'pages.type',
'pages.width',
'page_count',
'characters',
'teams',
'locations',
'credits',
'credits.person',
'credits.role',
}
def supports_credit_role(self, role: str) -> bool:
return role.casefold() in self._get_parseable_credits()
def supports_tags(self, archive: Archiver) -> bool:
return archive.supports_files()
def has_tags(self, archive: Archiver) -> bool:
return (
self.supports_tags(archive)
and self.file in archive.get_filename_list()
and self._validate_bytes(archive.read_file(self.file))
)
def remove_tags(self, archive: Archiver) -> bool:
return self.has_tags(archive) and archive.remove_file(self.file)
def read_tags(self, archive: Archiver) -> GenericMetadata:
if self.has_tags(archive):
metadata = archive.read_file(self.file) or b''
if self._validate_bytes(metadata):
return self._metadata_from_bytes(metadata)
return GenericMetadata()
def read_raw_tags(self, archive: Archiver) -> str:
if self.has_tags(archive):
return ET.tostring(ET.fromstring(archive.read_file(self.file)), encoding='unicode', xml_declaration=True)
return ''
def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool:
if self.supports_tags(archive):
xml = b''
if self.has_tags(archive):
xml = archive.read_file(self.file)
return archive.write_file(self.file, self._bytes_from_metadata(metadata, xml))
logger.warning('Archive (%s) does not support %s metadata', archive.name(), self.name())
return False
def name(self) -> str:
return 'Comic Info XML'
@classmethod
def _get_parseable_credits(cls) -> list[str]:
parsable_credits: list[str] = []
parsable_credits.extend(GenericMetadata.writer_synonyms)
parsable_credits.extend(GenericMetadata.penciller_synonyms)
parsable_credits.extend(GenericMetadata.inker_synonyms)
parsable_credits.extend(GenericMetadata.colorist_synonyms)
parsable_credits.extend(GenericMetadata.letterer_synonyms)
parsable_credits.extend(GenericMetadata.cover_synonyms)
parsable_credits.extend(GenericMetadata.editor_synonyms)
parsable_credits.extend(GenericMetadata.translator_synonyms)
return parsable_credits
def _metadata_from_bytes(self, string: bytes) -> GenericMetadata:
root = ET.fromstring(string)
return self._convert_xml_to_metadata(root)
def _bytes_from_metadata(self, metadata: GenericMetadata, xml: bytes = b'') -> bytes:
root = self._convert_metadata_to_xml(metadata, xml)
return ET.tostring(root, encoding='utf-8', xml_declaration=True)
def _convert_metadata_to_xml(self, metadata: GenericMetadata, xml: bytes = b'') -> ET.Element:
# shorthand for the metadata
md = metadata
if xml:
root = ET.fromstring(xml)
else:
# build a tree structure
root = ET.Element('ComicInfo')
root.attrib['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance'
root.attrib['xmlns:xsd'] = 'http://www.w3.org/2001/XMLSchema'
# helper func
def assign(cr_entry: str, md_entry: Any) -> None:
if md_entry:
text = str(md_entry)
if isinstance(md_entry, (list, set)):
text = ','.join(md_entry)
et_entry = root.find(cr_entry)
if et_entry is not None:
et_entry.text = text
else:
ET.SubElement(root, cr_entry).text = text
else:
et_entry = root.find(cr_entry)
if et_entry is not None:
root.remove(et_entry)
# need to specially process the credits, since they are structured
# differently than CIX
credit_writer_list = []
credit_penciller_list = []
credit_inker_list = []
credit_colorist_list = []
credit_letterer_list = []
credit_cover_list = []
credit_editor_list = []
credit_translator_list = []
# first, loop thru credits, and build a list for each role that CIX
# supports
for credit in metadata.credits:
if credit.role.casefold() in GenericMetadata.writer_synonyms:
credit_writer_list.append(credit.person.replace(',', ''))
if credit.role.casefold() in GenericMetadata.penciller_synonyms:
credit_penciller_list.append(credit.person.replace(',', ''))
if credit.role.casefold() in GenericMetadata.inker_synonyms:
credit_inker_list.append(credit.person.replace(',', ''))
if credit.role.casefold() in GenericMetadata.colorist_synonyms:
credit_colorist_list.append(credit.person.replace(',', ''))
if credit.role.casefold() in GenericMetadata.letterer_synonyms:
credit_letterer_list.append(credit.person.replace(',', ''))
if credit.role.casefold() in GenericMetadata.cover_synonyms:
credit_cover_list.append(credit.person.replace(',', ''))
if credit.role.casefold() in GenericMetadata.editor_synonyms:
credit_editor_list.append(credit.person.replace(',', ''))
if credit.role.casefold() in GenericMetadata.translator_synonyms:
credit_translator_list.append(credit.person.replace(',', ''))
assign('Series', md.series)
assign('Number', md.issue)
assign('Count', md.issue_count)
assign('Title', md.title)
assign('GTIN', md.gtin)
assign('Volume', md.volume)
assign('Genre', md.genres)
assign('Summary', md.description)
assign('Notes', md.notes)
assign('AlternateSeries', md.alternate_series)
assign('AlternateNumber', md.alternate_number)
assign('AlternateCount', md.alternate_count)
assign('StoryArc', md.story_arcs)
assign('SeriesGroup', md.series_groups)
assign('Publisher', md.publisher)
assign('Imprint', md.imprint)
assign('Day', md.day)
assign('Month', md.month)
assign('Year', md.year)
assign('LanguageISO', md.language)
assign('Web', ' '.join(u.url for u in md.web_links))
assign('Format', md.format)
assign('Manga', md.manga)
assign('BlackAndWhite', 'Yes' if md.black_and_white else None)
assign('AgeRating', md.maturity_rating)
assign('CommunityRating', md.critical_rating)
scan_info = md.scan_info or ''
if md.original_hash:
scan_info += f" sum:{md.original_hash}"
assign('ScanInformation', scan_info)
assign('Tags', md.tags)
assign('PageCount', md.page_count)
assign('Characters', md.characters)
assign('Teams', md.teams)
assign('Locations', md.locations)
assign('Writer', credit_writer_list)
assign('Penciller', credit_penciller_list)
assign('Inker', credit_inker_list)
assign('Colorist', credit_colorist_list)
assign('Letterer', credit_letterer_list)
assign('CoverArtist', credit_cover_list)
assign('Editor', credit_editor_list)
assign('Translator', credit_translator_list)
# loop and add the page entries under pages node
pages_node = root.find('Pages')
if pages_node is not None:
pages_node.clear()
else:
pages_node = ET.SubElement(root, 'Pages')
for page in md.pages:
page_node = ET.SubElement(pages_node, 'Page')
page_node.attrib = {'Image': str(page.display_index)}
if page.bookmark:
page_node.attrib['Bookmark'] = page.bookmark
if page.type:
page_node.attrib['Type'] = page.type
if page.double_page is not None:
page_node.attrib['DoublePage'] = str(page.double_page)
if page.height is not None:
page_node.attrib['ImageHeight'] = str(page.height)
if page.byte_size is not None:
page_node.attrib['ImageSize'] = str(page.byte_size)
if page.width is not None:
page_node.attrib['ImageWidth'] = str(page.width)
page_node.attrib = dict(sorted(page_node.attrib.items()))
ET.indent(root)
return root
def _convert_xml_to_metadata(self, root: ET.Element) -> GenericMetadata:
if root.tag != 'ComicInfo':
raise Exception('Not a ComicInfo file')
def get(name: str) -> str | None:
tag = root.find(name)
if tag is None:
return None
return tag.text
md = GenericMetadata()
md.series = utils.xlate(get('Series'))
md.issue = utils.xlate(get('Number'))
md.issue_count = utils.xlate_int(get('Count'))
md.title = utils.xlate(get('Title'))
md.gtin = utils.xlate(get('GTIN'))
md.volume = utils.xlate_int(get('Volume'))
md.genres = set(utils.split(get('Genre'), ','))
md.description = utils.xlate(get('Summary'))
md.notes = utils.xlate(get('Notes'))
md.alternate_series = utils.xlate(get('AlternateSeries'))
md.alternate_number = utils.xlate(get('AlternateNumber'))
md.alternate_count = utils.xlate_int(get('AlternateCount'))
md.story_arcs = utils.split(get('StoryArc'), ',')
md.series_groups = utils.split(get('SeriesGroup'), ',')
md.publisher = utils.xlate(get('Publisher'))
md.imprint = utils.xlate(get('Imprint'))
md.day = utils.xlate_int(get('Day'))
md.month = utils.xlate_int(get('Month'))
md.year = utils.xlate_int(get('Year'))
md.language = utils.xlate(get('LanguageISO'))
md.web_links = utils.split_urls(utils.xlate(get('Web')))
md.format = utils.xlate(get('Format'))
md.manga = utils.xlate(get('Manga'))
md.maturity_rating = utils.xlate(get('AgeRating'))
md.critical_rating = utils.xlate_float(get('CommunityRating'))
scan_info_list = (utils.xlate(get('ScanInformation')) or '').split()
for word in scan_info_list.copy():
if not word.startswith('sum:'):
continue
original_hash = FileHash.parse(word[4:])
if original_hash:
md.original_hash = original_hash
scan_info_list.remove(word)
break
if scan_info_list:
md.scan_info = ' '.join(scan_info_list)
md.tags = set(utils.split(get('Tags'), ','))
md.page_count = utils.xlate_int(get('PageCount'))
md.characters = set(utils.split(get('Characters'), ','))
md.teams = set(utils.split(get('Teams'), ','))
md.locations = set(utils.split(get('Locations'), ','))
tmp = utils.xlate(get('BlackAndWhite'))
if tmp is not None:
md.black_and_white = tmp.casefold() in {'yes', 'true', '1'}
# Now extract the credit info
for n in root:
if n.tag in {'Writer', 'Penciller', 'Inker', 'Colorist', 'Letterer', 'Editor'} and n.text is not None:
for name in utils.split(n.text, ','):
md.add_credit(name.strip(), n.tag)
if n.tag == 'CoverArtist' and n.text is not None:
for name in utils.split(n.text, ','):
md.add_credit(name.strip(), 'Cover')
# parse page data now
pages_node = root.find('Pages')
if pages_node is not None:
for i, page in enumerate(pages_node):
p: dict[str, Any] = page.attrib
md_page = PageMetadata(
filename='', # cix doesn't record the filename it just assumes it's always ordered the same
display_index=int(p.get('Image', i)),
archive_index=i,
bookmark=p.get('Bookmark', ''),
type='',
)
md_page.set_type(p.get('Type', ''))
if isinstance(p.get('DoublePage', None), str):
md_page.double_page = p['DoublePage'].casefold() in ('yes', 'true', '1')
if p.get('ImageHeight', '').isnumeric():
md_page.height = int(float(p['ImageHeight']))
if p.get('ImageWidth', '').isnumeric():
md_page.width = int(float(p['ImageWidth']))
if p.get('ImageSize', '').isnumeric():
md_page.byte_size = int(float(p['ImageSize']))
md.pages.append(md_page)
md.is_empty = False
return md
def _validate_bytes(self, string: bytes) -> bool:
"""Verify that the string actually contains CIX data in XML format."""
try:
root = ET.fromstring(string)
if root.tag != 'ComicInfo':
return False
except ET.ParseError:
return False
return True