mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Merge branch 'svg'
This commit is contained in:
commit
f2d4e7ff85
@ -10,7 +10,7 @@ from lxml.html.builder import HR, IMG
|
||||
|
||||
from calibre import sanitize_file_name
|
||||
from calibre.constants import iswindows
|
||||
from calibre.ebooks.docx.names import barename
|
||||
from calibre.ebooks.docx.names import SVG_BLIP_URI, barename
|
||||
from calibre.utils.filenames import ascii_filename
|
||||
from calibre.utils.img import image_to_data, resize_to_fit
|
||||
from calibre.utils.imghdr import what
|
||||
@ -236,9 +236,12 @@ class Images:
|
||||
name = image_filename(name)
|
||||
alt = pr.get('descr') or alt
|
||||
for a in XPath('descendant::a:blip[@r:embed or @r:link]')(pic):
|
||||
rid = get(a, 'r:embed')
|
||||
if not rid:
|
||||
rid = get(a, 'r:link')
|
||||
rid = get(a, 'r:embed') or get(a, 'r:link')
|
||||
for asvg in XPath(f'./a:extLst/a:ext[@uri="{SVG_BLIP_URI}"]/asvg:svgBlip[@r:embed or @r:link]')(a):
|
||||
svg_rid = get(asvg, 'r:embed') or get(asvg, 'r:link')
|
||||
if svg_rid and svg_rid in self.rid_map:
|
||||
rid = svg_rid
|
||||
break
|
||||
if rid and rid in self.rid_map:
|
||||
try:
|
||||
src = self.generate_filename(rid, name)
|
||||
|
@ -46,6 +46,7 @@ TRANSITIONAL_NAMESPACES = {
|
||||
'xml': 'http://www.w3.org/XML/1998/namespace',
|
||||
# Drawing
|
||||
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||
'a14': 'http://schemas.microsoft.com/office/drawing/2010/main',
|
||||
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
|
||||
'mv': 'urn:schemas-microsoft-com:mac:vml',
|
||||
'pic': 'http://schemas.openxmlformats.org/drawingml/2006/picture',
|
||||
@ -63,7 +64,9 @@ TRANSITIONAL_NAMESPACES = {
|
||||
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
|
||||
# Dublin Core document properties
|
||||
'dcmitype': 'http://purl.org/dc/dcmitype/',
|
||||
'dcterms': 'http://purl.org/dc/terms/'
|
||||
'dcterms': 'http://purl.org/dc/terms/',
|
||||
# SVG embeds
|
||||
'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main',
|
||||
}
|
||||
|
||||
STRICT_NAMESPACES = {
|
||||
@ -73,6 +76,8 @@ STRICT_NAMESPACES = {
|
||||
'http://schemas.openxmlformats.org/drawingml/2006', 'http://purl.oclc.org/ooxml/drawingml')
|
||||
for k, v in iteritems(TRANSITIONAL_NAMESPACES)
|
||||
}
|
||||
SVG_BLIP_URI = '{96DAC541-7B7A-43D3-8B79-37D633B846F1}'
|
||||
USE_LOCAL_DPI_URI = '{28A0092B-C50C-407E-A947-70E740481C1C}'
|
||||
# }}}
|
||||
|
||||
|
||||
|
@ -440,7 +440,7 @@ class Convert:
|
||||
|
||||
self.styles_manager = StylesManager(self.docx.namespace, self.log, self.mi.language)
|
||||
self.links_manager = LinksManager(self.docx.namespace, self.docx.document_relationships, self.log)
|
||||
self.images_manager = ImagesManager(self.oeb, self.docx.document_relationships, self.opts)
|
||||
self.images_manager = ImagesManager(self.oeb, self.docx.document_relationships, self.opts, self.svg_rasterizer)
|
||||
self.lists_manager = ListsManager(self.docx)
|
||||
self.fonts_manager = FontsManager(self.docx.namespace, self.oeb, self.opts)
|
||||
self.blocks = Blocks(self.docx.namespace, self.styles_manager, self.links_manager)
|
||||
@ -481,9 +481,7 @@ class Convert:
|
||||
|
||||
def process_item(self, item):
|
||||
self.current_item = item
|
||||
stylizer = self.svg_rasterizer.stylizer_cache.get(item)
|
||||
if stylizer is None:
|
||||
stylizer = Stylizer(item.data, item.href, self.oeb, self.opts, profile=self.opts.output_profile, base_css=self.base_css)
|
||||
stylizer = self.svg_rasterizer.stylizer(item)
|
||||
self.abshref = self.images_manager.abshref = item.abshref
|
||||
|
||||
self.current_lang = lang_for_tag(item.data) or self.styles_manager.document_lang
|
||||
|
@ -12,11 +12,11 @@ from lxml import etree
|
||||
|
||||
from calibre import fit_image
|
||||
from calibre.ebooks.docx.images import pt_to_emu
|
||||
from calibre.ebooks.docx.names import USE_LOCAL_DPI_URI, SVG_BLIP_URI
|
||||
from calibre.ebooks.oeb.base import urlquote, urlunquote
|
||||
from calibre.utils.filenames import ascii_filename
|
||||
from calibre.utils.imghdr import identify
|
||||
from calibre.utils.resources import get_image_path as I
|
||||
from polyglot.builtins import iteritems, itervalues
|
||||
|
||||
Image = namedtuple('Image', 'rid fname width height fmt item')
|
||||
|
||||
@ -39,13 +39,26 @@ def get_image_margins(style):
|
||||
|
||||
class ImagesManager:
|
||||
|
||||
def __init__(self, oeb, document_relationships, opts):
|
||||
def __init__(self, oeb, document_relationships, opts, svg_rasterizer):
|
||||
self.oeb, self.log = oeb, oeb.log
|
||||
self.svg_rasterizer = svg_rasterizer
|
||||
self.page_width, self.page_height = opts.output_profile.width_pts, opts.output_profile.height_pts
|
||||
self.images = {}
|
||||
self.seen_filenames = set()
|
||||
self.document_relationships = document_relationships
|
||||
self.count = 0
|
||||
self.svg_images = {}
|
||||
|
||||
def read_svg(self, href):
|
||||
if href not in self.svg_images:
|
||||
item = self.oeb.manifest.hrefs.get(href) or self.oeb.manifest.hrefs.get(urlquote(href))
|
||||
if item is None:
|
||||
self.log.warning('Failed to find image:', href)
|
||||
return
|
||||
image_fname = 'media/' + self.create_filename(href, 'svg')
|
||||
image_rid = self.document_relationships.add_image(image_fname)
|
||||
self.svg_images[href] = Image(image_rid, image_fname, -1, -1, 'svg', item)
|
||||
return self.svg_images[href]
|
||||
|
||||
def read_image(self, href):
|
||||
if href not in self.images:
|
||||
@ -84,6 +97,12 @@ class ImagesManager:
|
||||
|
||||
def create_image_markup(self, html_img, stylizer, href, as_block=False):
|
||||
# TODO: img inside a link (clickable image)
|
||||
svg_rid = ''
|
||||
svghref = self.svg_rasterizer.svg_originals.get(href)
|
||||
if svghref:
|
||||
si = self.read_svg(svghref)
|
||||
if si:
|
||||
svg_rid = si.rid
|
||||
style = stylizer.style(html_img)
|
||||
floating = style['float']
|
||||
if floating not in {'left', 'right'}:
|
||||
@ -134,7 +153,7 @@ class ImagesManager:
|
||||
if fake_margins:
|
||||
# DOCX does not support setting margins for inline images, so we
|
||||
# fake it by using effect extents to simulate margins
|
||||
makeelement(parent, 'wp:effectExtent', **{k[-1].lower():v for k, v in iteritems(get_image_margins(style))})
|
||||
makeelement(parent, 'wp:effectExtent', **{k[-1].lower():v for k, v in get_image_margins(style).items()})
|
||||
else:
|
||||
makeelement(parent, 'wp:effectExtent', l='0', r='0', t='0', b='0')
|
||||
if floating is not None:
|
||||
@ -143,10 +162,10 @@ class ImagesManager:
|
||||
makeelement(parent, 'wp:wrapTopAndBottom')
|
||||
else:
|
||||
makeelement(parent, 'wp:wrapSquare', wrapText='bothSides')
|
||||
self.create_docx_image_markup(parent, name, html_img.get('alt') or name, img.rid, width, height)
|
||||
self.create_docx_image_markup(parent, name, html_img.get('alt') or name, img.rid, width, height, svg_rid=svg_rid)
|
||||
return ans
|
||||
|
||||
def create_docx_image_markup(self, parent, name, alt, img_rid, width, height):
|
||||
def create_docx_image_markup(self, parent, name, alt, img_rid, width, height, svg_rid=''):
|
||||
makeelement, namespaces = self.document_relationships.namespace.makeelement, self.document_relationships.namespace.namespaces
|
||||
makeelement(parent, 'wp:docPr', id=str(self.count), name=name, descr=alt)
|
||||
makeelement(makeelement(parent, 'wp:cNvGraphicFramePr'), 'a:graphicFrameLocks', noChangeAspect="1")
|
||||
@ -157,7 +176,11 @@ class ImagesManager:
|
||||
makeelement(nvPicPr, 'pic:cNvPr', id='0', name=name, descr=alt)
|
||||
makeelement(nvPicPr, 'pic:cNvPicPr')
|
||||
bf = makeelement(pic, 'pic:blipFill')
|
||||
makeelement(bf, 'a:blip', r_embed=img_rid)
|
||||
blip = makeelement(bf, 'a:blip', r_embed=img_rid)
|
||||
if svg_rid:
|
||||
ext_list = makeelement(blip, 'a:extLst')
|
||||
makeelement(makeelement(ext_list, 'a:ext', uri=USE_LOCAL_DPI_URI), 'a14:useLocalDpi', val='0')
|
||||
makeelement(makeelement(ext_list, 'a:ext', uri=SVG_BLIP_URI), 'asvg:svgBlip', r_embed=svg_rid)
|
||||
makeelement(makeelement(bf, 'a:stretch'), 'a:fillRect')
|
||||
spPr = makeelement(pic, 'pic:spPr')
|
||||
xfrm = makeelement(spPr, 'a:xfrm')
|
||||
@ -178,8 +201,10 @@ class ImagesManager:
|
||||
return fname
|
||||
|
||||
def serialize(self, images_map):
|
||||
for img in itervalues(self.images):
|
||||
for img in self.images.values():
|
||||
images_map['word/' + img.fname] = partial(self.get_data, img.item)
|
||||
for img in self.svg_images.values():
|
||||
images_map['word/' + img.fname] = lambda: img.item.data_as_bytes_or_none
|
||||
|
||||
def get_data(self, item):
|
||||
try:
|
||||
|
@ -15,6 +15,7 @@ from collections import defaultdict
|
||||
from itertools import count
|
||||
from lxml import etree, html
|
||||
from operator import attrgetter
|
||||
from typing import Optional
|
||||
|
||||
from calibre import as_unicode, force_unicode, get_types_map, isbytestring
|
||||
from calibre.constants import __version__, filesystem_encoding
|
||||
@ -1017,6 +1018,12 @@ class Manifest:
|
||||
|
||||
# }}}
|
||||
|
||||
@property
|
||||
def data_as_bytes_or_none(self) -> Optional[bytes]:
|
||||
if self._loader is None:
|
||||
return None
|
||||
return self._loader(getattr(self, 'html_input_href', self.href))
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""Provides MIME type sensitive access to the manifest
|
||||
@ -1033,10 +1040,7 @@ class Manifest:
|
||||
"""
|
||||
data = self._data
|
||||
if data is None:
|
||||
if self._loader is None:
|
||||
return None
|
||||
data = self._loader(getattr(self, 'html_input_href',
|
||||
self.href))
|
||||
data = self.data_as_bytes_or_none
|
||||
try:
|
||||
mt = self.media_type.lower()
|
||||
except Exception:
|
||||
|
@ -5,32 +5,44 @@ SVG rasterization transform.
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2008, Marshall T. Vandegrift <llasram@gmail.com>'
|
||||
|
||||
import os, re
|
||||
import os
|
||||
import re
|
||||
from base64 import standard_b64encode
|
||||
from functools import lru_cache
|
||||
|
||||
from lxml import etree
|
||||
from qt.core import (
|
||||
Qt, QByteArray, QBuffer, QIODevice, QColor, QImage, QPainter, QSvgRenderer)
|
||||
from calibre.ebooks.oeb.base import XHTML, XLINK
|
||||
from calibre.ebooks.oeb.base import SVG_MIME, PNG_MIME
|
||||
from calibre.ebooks.oeb.base import xml2str, xpath
|
||||
from calibre.ebooks.oeb.base import urlnormalize
|
||||
QBuffer, QByteArray, QColor, QImage, QIODevice, QPainter, QSvgRenderer, Qt,
|
||||
)
|
||||
|
||||
from calibre import guess_type
|
||||
from calibre.ebooks.oeb.base import (
|
||||
PNG_MIME, SVG_MIME, XHTML, XLINK, urlnormalize, xml2str, xpath,
|
||||
)
|
||||
from calibre.ebooks.oeb.stylizer import Stylizer
|
||||
from calibre.ptempfile import PersistentTemporaryFile
|
||||
from calibre.utils.imghdr import what
|
||||
from polyglot.urllib import urldefrag
|
||||
|
||||
IMAGE_TAGS = {XHTML('img'), XHTML('object')}
|
||||
KEEP_ATTRS = {'class', 'style', 'width', 'height', 'align'}
|
||||
TEST_SVG = b'''
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
|
||||
<path d="M4.5 11H3v4h4v-1.5H4.5V11zM3 7h1.5V4.5H7V3H3v4zm10.5 6.5H11V15h4v-4h-1.5v2.5zM11 3v1.5h2.5V7H15V3h-4z"/>
|
||||
</svg>'''
|
||||
|
||||
def test_svg(): # {{{
|
||||
TEST_PNG_DATA_URI='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAWJQTFRFAAAAAAAAAAAAAAAAAAAAAQEAAgIBAwIBBgQCBwUCCAYDCggECwkEDgsFDwwFEA0GHRcKHxkLIBkLIxwMJR0NJx8OKCAOKCAPKSAPMScSPTAWQTQXQzUYSjsaSjsbSzsbUD8dUUAdVEMeWkggW0ggW0ghW0khXUohYk4ja1Umb1gocVoocVopclspc1spdV0qd18reF8riW0xjXEzl3g2mns3nn04nn45n345oIA6ooE7o4I7pII7pIM7pYQ7p4U8qYY8rYo+s45Bxp1Hx55Hy6FJy6JJzaRJz6RKz6RLz6VL0qdL1KpM1apM1qtN16xN2KxN2K1O2a1O2q1O265P3K9P3bBQ3rBP37FP37FQ37JQ4rNR47VR5LVR7LxV7bxV7r1V7r5W8L9W8MBW8b9V8b9W8cBW8cBX8sBW8sBX8sFW8sFX88BX88FW88FX88FY88JX88JY9MFX9MJX9MJY9MNYSw0rOAAAAAR0Uk5T2+rr8giKtGMAAAFDSURBVDjLhdNFUwNBEIbhJWkkuLu7u5PgHtwWl0CGnW34aJLl/3OgUlRlGfKepqafmstUW1Yw8E9By6IMWVn/z7OsQOpYNrE0H4lEwuFwZHmyLnUb+AUzIiLMItDgrWIfKH3mnz4RA6PX/8Im8xuEgVfxxG33g+rVi9OT46OdPQ0kDgv8gCg3FMrLphkNyCD9BYiIqEErraP5ZrDGDrw2MoIhsPACGUH5g2gVqzWDKQ/gETKCZmHwbo4ZbHhJ1q1kBMMJCKbJCCof35V+qjCDOUCrMTKCFkc8vU5GENpW8NwmMxhVccYsGUHVvWKOFhlBySJicV6u7+7s6Ozq6anxgT44Lwy4jlKK4br96WDl09GA/gA4zp7gLh2MM3MS+EgCGl+iD9JB4cDZzbV9ZV/atn1+frvfaPhuX4HMq0cZsjKt/zfXXmDab9zjGwAAAABJRU5ErkJggg=='
|
||||
return f'''
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<path d="M4.5 11H3v4h4v-1.5H4.5V11zM3 7h1.5V4.5H7V3H3v4zm10.5 6.5H11V15h4v-4h-1.5v2.5zM11 3v1.5h2.5V7H15V3h-4z"/>
|
||||
<image width="32" height="32" x="32" y="32" xlink:href="{TEST_PNG_DATA_URI}"/>
|
||||
</svg>'''.encode()
|
||||
# }}}
|
||||
|
||||
|
||||
class Unavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def rasterize_svg(data=TEST_SVG, sizes=(), width=0, height=0, print=None, fmt='PNG', as_qimage=False):
|
||||
def rasterize_svg(data=None, sizes=(), width=0, height=0, print=None, fmt='PNG', as_qimage=False):
|
||||
if data is None:
|
||||
data = test_svg()
|
||||
svg = QSvgRenderer(QByteArray(data))
|
||||
size = svg.defaultSize()
|
||||
if size.width() == 100 and size.height() == 100 and sizes:
|
||||
@ -54,10 +66,16 @@ def rasterize_svg(data=TEST_SVG, sizes=(), width=0, height=0, print=None, fmt='P
|
||||
return array.data()
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def data_url(mime_type: str, data: bytes) -> str:
|
||||
return f'data:{mime_type};base64,' + standard_b64encode(data).decode('ascii')
|
||||
|
||||
|
||||
class SVGRasterizer:
|
||||
|
||||
def __init__(self, base_css=''):
|
||||
def __init__(self, base_css='', save_svg_originals=False):
|
||||
self.base_css = base_css
|
||||
self.save_svg_originals = save_svg_originals
|
||||
from calibre.gui2 import must_use_qt
|
||||
must_use_qt()
|
||||
|
||||
@ -71,20 +89,15 @@ class SVGRasterizer:
|
||||
|
||||
def __call__(self, oeb, context):
|
||||
oeb.logger.info('Rasterizing SVG images...')
|
||||
self.temp_files = []
|
||||
self.stylizer_cache = {}
|
||||
self.oeb = oeb
|
||||
self.opts = context
|
||||
self.profile = context.dest
|
||||
self.images = {}
|
||||
self.dataize_manifest()
|
||||
self.svg_originals = {}
|
||||
self.scan_for_linked_resources_in_manifest()
|
||||
self.rasterize_spine()
|
||||
self.rasterize_cover()
|
||||
for pt in self.temp_files:
|
||||
try:
|
||||
os.remove(pt)
|
||||
except:
|
||||
pass
|
||||
|
||||
def rasterize_svg(self, elem, width=0, height=0, format='PNG'):
|
||||
view_box = elem.get('viewBox', elem.get('viewbox', None))
|
||||
@ -110,38 +123,41 @@ class SVGRasterizer:
|
||||
|
||||
return rasterize_svg(xml2str(elem, with_tail=False), sizes=sizes, width=width, height=height, print=logger.info, fmt=format)
|
||||
|
||||
def dataize_manifest(self):
|
||||
def scan_for_linked_resources_in_manifest(self):
|
||||
for item in self.oeb.manifest.values():
|
||||
if item.media_type == SVG_MIME and item.data is not None:
|
||||
self.dataize_svg(item)
|
||||
self.scan_for_linked_resources_in_svg(item)
|
||||
|
||||
def dataize_svg(self, item, svg=None):
|
||||
def scan_for_linked_resources_in_svg(self, item, svg=None):
|
||||
if svg is None:
|
||||
svg = item.data
|
||||
hrefs = self.oeb.manifest.hrefs
|
||||
ha = XLINK('href')
|
||||
for elem in xpath(svg, '//svg:*[@xl:href]'):
|
||||
href = urlnormalize(elem.attrib[XLINK('href')])
|
||||
href = urlnormalize(elem.get(ha))
|
||||
path = urldefrag(href)[0]
|
||||
if not path:
|
||||
continue
|
||||
abshref = item.abshref(path)
|
||||
if abshref not in hrefs:
|
||||
linkee = hrefs.get(abshref)
|
||||
if linkee is None:
|
||||
continue
|
||||
linkee = hrefs[abshref]
|
||||
data = linkee.bytes_representation
|
||||
ext = what(None, data) or 'jpg'
|
||||
with PersistentTemporaryFile(suffix='.'+ext) as pt:
|
||||
pt.write(data)
|
||||
self.temp_files.append(pt.name)
|
||||
elem.attrib[XLINK('href')] = pt.name
|
||||
ext = what(None, data)
|
||||
if not ext:
|
||||
continue
|
||||
mt = guess_type('file.'+ext)[0]
|
||||
if not mt or not mt.startswith('image/'):
|
||||
continue
|
||||
elem.set(ha, data_url(mt, data))
|
||||
|
||||
return svg
|
||||
|
||||
def stylizer(self, item):
|
||||
ans = self.stylizer_cache.get(item, None)
|
||||
if ans is None:
|
||||
ans = Stylizer(item.data, item.href, self.oeb, self.opts,
|
||||
ans = self.stylizer_cache[item] = Stylizer(item.data, item.href, self.oeb, self.opts,
|
||||
self.profile, base_css=self.base_css)
|
||||
self.stylizer_cache[item] = ans
|
||||
return ans
|
||||
|
||||
def rasterize_spine(self):
|
||||
@ -172,13 +188,19 @@ class SVGRasterizer:
|
||||
height = style['height']
|
||||
width = (width / 72) * self.profile.dpi
|
||||
height = (height / 72) * self.profile.dpi
|
||||
elem = self.dataize_svg(item, elem)
|
||||
self.scan_for_linked_resources_in_svg(item, elem)
|
||||
data = self.rasterize_svg(elem, width, height)
|
||||
manifest = self.oeb.manifest
|
||||
href = os.path.splitext(item.href)[0] + '.png'
|
||||
id, href = manifest.generate(item.id, href)
|
||||
manifest.add(id, href, PNG_MIME, data=data)
|
||||
img = elem.makeelement(XHTML('img'), src=item.relhref(href))
|
||||
if self.save_svg_originals:
|
||||
svg_bytes = etree.tostring(elem, encoding='utf-8', xml_declaration=True, pretty_print=True, with_tail=False)
|
||||
svg_id, svg_href = manifest.generate(item.id, 'inline.svg')
|
||||
manifest.add(svg_id, svg_href, SVG_MIME, data=svg_bytes)
|
||||
self.svg_originals[href] = svg_href
|
||||
img.tail = elem.tail
|
||||
elem.getparent().replace(elem, img)
|
||||
for prop in ('width', 'height'):
|
||||
if prop in elem.attrib:
|
||||
@ -215,6 +237,7 @@ class SVGRasterizer:
|
||||
id, href = manifest.generate(svgitem.id, href)
|
||||
manifest.add(id, href, PNG_MIME, data=data)
|
||||
self.images[key] = href
|
||||
self.svg_originals[href] = svgitem.href
|
||||
elem.tag = XHTML('img')
|
||||
for attr in elem.attrib:
|
||||
if attr not in KEEP_ATTRS:
|
||||
@ -244,3 +267,7 @@ class SVGRasterizer:
|
||||
id, href = self.oeb.manifest.generate(cover.id, href)
|
||||
self.oeb.manifest.add(id, href, PNG_MIME, data=data)
|
||||
covers[0].value = id
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
open('/t/test-svg-rasterization.png', 'wb').write(rasterize_svg())
|
||||
|
Loading…
x
Reference in New Issue
Block a user