mirror of
https://github.com/gryf/ebook-converter.git
synced 2026-02-25 03:35:49 +01:00
Here is the first batch of modules, which are needed for converting several formats to LRF. Some of the logic has been change, more cleanups will follow.
809 lines
32 KiB
Python
809 lines
32 KiB
Python
# -*- encoding: utf-8 -*-
|
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
|
|
'''
|
|
CSS property propagation class.
|
|
'''
|
|
|
|
__license__ = 'GPL v3'
|
|
__copyright__ = '2008, Marshall T. Vandegrift <llasram@gmail.com>'
|
|
|
|
import os, re, logging, copy, unicodedata, numbers
|
|
from operator import itemgetter
|
|
from weakref import WeakKeyDictionary
|
|
from xml.dom import SyntaxErr as CSSSyntaxError
|
|
from css_parser.css import (CSSStyleRule, CSSPageRule, CSSFontFaceRule,
|
|
cssproperties)
|
|
from css_parser import (profile as cssprofiles, parseString, parseStyle, log as
|
|
css_parser_log, CSSParser, profiles, replaceUrls)
|
|
from ebook_converter import force_unicode, as_unicode
|
|
from ebook_converter.ebooks import unit_convert
|
|
from ebook_converter.ebooks.oeb.base import XHTML, XHTML_NS, CSS_MIME, OEB_STYLES, xpath, urlnormalize
|
|
from ebook_converter.ebooks.oeb.normalize_css import DEFAULTS, normalizers
|
|
from ebook_converter.css_selectors import Select, SelectorError, INAPPROPRIATE_PSEUDO_CLASSES
|
|
from ebook_converter.polyglot.builtins import iteritems, unicode_type, filter
|
|
from ebook_converter.tinycss.media3 import CSSMedia3Parser
|
|
|
|
css_parser_log.setLevel(logging.WARN)
|
|
|
|
_html_css_stylesheet = None
|
|
|
|
|
|
def html_css_stylesheet():
|
|
global _html_css_stylesheet
|
|
if _html_css_stylesheet is None:
|
|
with open(P('templates/html.css'), 'rb') as f:
|
|
html_css = f.read().decode('utf-8')
|
|
_html_css_stylesheet = parseString(html_css, validate=False)
|
|
return _html_css_stylesheet
|
|
|
|
|
|
INHERITED = {
|
|
'azimuth', 'border-collapse', 'border-spacing', 'caption-side', 'color',
|
|
'cursor', 'direction', 'elevation', 'empty-cells', 'font-family',
|
|
'font-size', 'font-style', 'font-variant', 'font-weight', 'letter-spacing',
|
|
'line-height', 'list-style-image', 'list-style-position',
|
|
'list-style-type', 'orphans', 'page-break-inside', 'pitch-range', 'pitch',
|
|
'quotes', 'richness', 'speak-header', 'speak-numeral', 'speak-punctuation',
|
|
'speak', 'speech-rate', 'stress', 'text-align', 'text-indent',
|
|
'text-transform', 'visibility', 'voice-family', 'volume', 'white-space',
|
|
'widows', 'word-spacing', 'text-shadow',
|
|
}
|
|
|
|
FONT_SIZE_NAMES = {
|
|
'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'
|
|
}
|
|
|
|
ALLOWED_MEDIA_TYPES = frozenset({'screen', 'all', 'aural', 'amzn-kf8'})
|
|
IGNORED_MEDIA_FEATURES = frozenset('width min-width max-width height min-height max-height device-width min-device-width max-device-width device-height min-device-height max-device-height aspect-ratio min-aspect-ratio max-aspect-ratio device-aspect-ratio min-device-aspect-ratio max-device-aspect-ratio color min-color max-color color-index min-color-index max-color-index monochrome min-monochrome max-monochrome -webkit-min-device-pixel-ratio resolution min-resolution max-resolution scan grid'.split()) # noqa
|
|
|
|
|
|
def media_ok(raw):
|
|
if not raw:
|
|
return True
|
|
if raw == 'amzn-mobi': # Optimization for the common case
|
|
return False
|
|
|
|
def query_ok(mq):
|
|
matched = True
|
|
if mq.media_type not in ALLOWED_MEDIA_TYPES:
|
|
matched = False
|
|
# Media queries that test for device specific features always fail
|
|
for media_feature, expr in mq.expressions:
|
|
if media_feature in IGNORED_MEDIA_FEATURES:
|
|
matched = False
|
|
return mq.negated ^ matched
|
|
|
|
try:
|
|
for mq in CSSMedia3Parser().parse_stylesheet('@media %s {}' % raw).rules[0].media:
|
|
if query_ok(mq):
|
|
return True
|
|
return False
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
|
|
def test_media_ok():
|
|
assert media_ok(None)
|
|
assert media_ok('')
|
|
assert not media_ok('amzn-mobi')
|
|
assert media_ok('amzn-kf8')
|
|
assert media_ok('screen')
|
|
assert media_ok('only screen')
|
|
assert not media_ok('not screen')
|
|
assert not media_ok('(device-width:10px)')
|
|
assert media_ok('screen, (device-width:10px)')
|
|
assert not media_ok('screen and (device-width:10px)')
|
|
|
|
|
|
class StylizerRules(object):
|
|
|
|
def __init__(self, opts, profile, stylesheets):
|
|
self.opts, self.profile, self.stylesheets = opts, profile, stylesheets
|
|
|
|
index = 0
|
|
self.rules = []
|
|
self.page_rule = {}
|
|
self.font_face_rules = []
|
|
for sheet_index, stylesheet in enumerate(stylesheets):
|
|
href = stylesheet.href
|
|
for rule in stylesheet.cssRules:
|
|
if rule.type == rule.MEDIA_RULE:
|
|
if media_ok(rule.media.mediaText):
|
|
for subrule in rule.cssRules:
|
|
self.rules.extend(self.flatten_rule(subrule, href, index, is_user_agent_sheet=sheet_index==0))
|
|
index += 1
|
|
else:
|
|
self.rules.extend(self.flatten_rule(rule, href, index, is_user_agent_sheet=sheet_index==0))
|
|
index = index + 1
|
|
self.rules.sort(key=itemgetter(0)) # sort by specificity
|
|
|
|
def flatten_rule(self, rule, href, index, is_user_agent_sheet=False):
|
|
results = []
|
|
sheet_index = 0 if is_user_agent_sheet else 1
|
|
if isinstance(rule, CSSStyleRule):
|
|
style = self.flatten_style(rule.style)
|
|
for selector in rule.selectorList:
|
|
specificity = (sheet_index,) + selector.specificity + (index,)
|
|
text = selector.selectorText
|
|
selector = list(selector.seq)
|
|
results.append((specificity, selector, style, text, href))
|
|
elif isinstance(rule, CSSPageRule):
|
|
style = self.flatten_style(rule.style)
|
|
self.page_rule.update(style)
|
|
elif isinstance(rule, CSSFontFaceRule):
|
|
if rule.style.length > 1:
|
|
# Ignore the meaningless font face rules generated by the
|
|
# benighted MS Word that contain only a font-family declaration
|
|
# and nothing else
|
|
self.font_face_rules.append(rule)
|
|
return results
|
|
|
|
def flatten_style(self, cssstyle):
|
|
style = {}
|
|
for prop in cssstyle:
|
|
name = prop.name
|
|
normalizer = normalizers.get(name, None)
|
|
if normalizer is not None:
|
|
style.update(normalizer(name, prop.cssValue))
|
|
elif name == 'text-align':
|
|
style['text-align'] = self._apply_text_align(prop.value)
|
|
else:
|
|
style[name] = prop.value
|
|
if 'font-size' in style:
|
|
size = style['font-size']
|
|
if size == 'normal':
|
|
size = 'medium'
|
|
if size == 'smallest':
|
|
size = 'xx-small'
|
|
if size in FONT_SIZE_NAMES:
|
|
style['font-size'] = "%.1frem" % (self.profile.fnames[size] / float(self.profile.fbase))
|
|
if '-epub-writing-mode' in style:
|
|
for x in ('-webkit-writing-mode', 'writing-mode'):
|
|
style[x] = style.get(x, style['-epub-writing-mode'])
|
|
return style
|
|
|
|
def _apply_text_align(self, text):
|
|
if text in ('left', 'justify') and self.opts.change_justification in ('left', 'justify'):
|
|
text = self.opts.change_justification
|
|
return text
|
|
|
|
def same_rules(self, opts, profile, stylesheets):
|
|
if self.opts != opts:
|
|
# it's unlikely to happen, but better safe than sorry
|
|
return False
|
|
if self.profile != profile:
|
|
return False
|
|
if len(self.stylesheets) != len(stylesheets):
|
|
return False
|
|
for index, stylesheet in enumerate(self.stylesheets):
|
|
if stylesheet != stylesheets[index]:
|
|
return False
|
|
return True
|
|
|
|
|
|
class Stylizer(object):
|
|
STYLESHEETS = WeakKeyDictionary()
|
|
|
|
def __init__(self, tree, path, oeb, opts, profile=None,
|
|
extra_css='', user_css='', base_css=''):
|
|
self.oeb, self.opts = oeb, opts
|
|
self.profile = profile
|
|
if self.profile is None:
|
|
# Use the default profile. This should really be using
|
|
# opts.output_profile, but I don't want to risk changing it, as
|
|
# doing so might well have hard to debug font size effects.
|
|
from ebook_converter.customize.ui import output_profiles
|
|
for x in output_profiles():
|
|
if x.short_name == 'default':
|
|
self.profile = x
|
|
break
|
|
if self.profile is None:
|
|
# Just in case the default profile is removed in the future :)
|
|
self.profile = opts.output_profile
|
|
self.body_font_size = self.profile.fbase
|
|
self.logger = oeb.logger
|
|
item = oeb.manifest.hrefs[path]
|
|
basename = os.path.basename(path)
|
|
cssname = os.path.splitext(basename)[0] + '.css'
|
|
stylesheets = [html_css_stylesheet()]
|
|
if base_css:
|
|
stylesheets.append(parseString(base_css, validate=False))
|
|
style_tags = xpath(tree, '//*[local-name()="style" or local-name()="link"]')
|
|
|
|
# Add css_parser parsing profiles from output_profile
|
|
for profile in self.opts.output_profile.extra_css_modules:
|
|
cssprofiles.addProfile(profile['name'],
|
|
profile['props'],
|
|
profile['macros'])
|
|
|
|
parser = CSSParser(fetcher=self._fetch_css_file,
|
|
log=logging.getLogger('calibre.css'))
|
|
for elem in style_tags:
|
|
if (elem.tag == XHTML('style') and elem.get('type', CSS_MIME) in OEB_STYLES and media_ok(elem.get('media'))):
|
|
text = elem.text if elem.text else ''
|
|
for x in elem:
|
|
t = getattr(x, 'text', None)
|
|
if t:
|
|
text += '\n\n' + force_unicode(t, 'utf-8')
|
|
t = getattr(x, 'tail', None)
|
|
if t:
|
|
text += '\n\n' + force_unicode(t, 'utf-8')
|
|
if text:
|
|
text = oeb.css_preprocessor(text)
|
|
# We handle @import rules separately
|
|
parser.setFetcher(lambda x: ('utf-8', b''))
|
|
stylesheet = parser.parseString(text, href=cssname,
|
|
validate=False)
|
|
parser.setFetcher(self._fetch_css_file)
|
|
for rule in stylesheet.cssRules:
|
|
if rule.type == rule.IMPORT_RULE:
|
|
ihref = item.abshref(rule.href)
|
|
if not media_ok(rule.media.mediaText):
|
|
continue
|
|
hrefs = self.oeb.manifest.hrefs
|
|
if ihref not in hrefs:
|
|
self.logger.warn('Ignoring missing stylesheet in @import rule:', rule.href)
|
|
continue
|
|
sitem = hrefs[ihref]
|
|
if sitem.media_type not in OEB_STYLES:
|
|
self.logger.warn('CSS @import of non-CSS file %r' % rule.href)
|
|
continue
|
|
stylesheets.append(sitem.data)
|
|
# Make links to resources absolute, since these rules will
|
|
# be folded into a stylesheet at the root
|
|
replaceUrls(stylesheet, item.abshref,
|
|
ignoreImportRules=True)
|
|
stylesheets.append(stylesheet)
|
|
elif (elem.tag == XHTML('link') and elem.get('href') and elem.get(
|
|
'rel', 'stylesheet').lower() == 'stylesheet' and elem.get(
|
|
'type', CSS_MIME).lower() in OEB_STYLES and media_ok(elem.get('media'))
|
|
):
|
|
href = urlnormalize(elem.attrib['href'])
|
|
path = item.abshref(href)
|
|
sitem = oeb.manifest.hrefs.get(path, None)
|
|
if sitem is None:
|
|
self.logger.warn(
|
|
'Stylesheet %r referenced by file %r not in manifest' %
|
|
(path, item.href))
|
|
continue
|
|
if not hasattr(sitem.data, 'cssRules'):
|
|
self.logger.warn(
|
|
'Stylesheet %r referenced by file %r is not CSS'%(path,
|
|
item.href))
|
|
continue
|
|
stylesheets.append(sitem.data)
|
|
csses = {'extra_css':extra_css, 'user_css':user_css}
|
|
for w, x in csses.items():
|
|
if x:
|
|
try:
|
|
text = x
|
|
stylesheet = parser.parseString(text, href=cssname,
|
|
validate=False)
|
|
stylesheets.append(stylesheet)
|
|
except Exception:
|
|
self.logger.exception('Failed to parse %s, ignoring.'%w)
|
|
self.logger.debug('Bad css: ')
|
|
self.logger.debug(x)
|
|
|
|
# using oeb to store the rules, page rule and font face rules
|
|
# and generating them again if opts, profile or stylesheets are different
|
|
if (not hasattr(self.oeb, 'stylizer_rules')) \
|
|
or not self.oeb.stylizer_rules.same_rules(self.opts, self.profile, stylesheets):
|
|
self.oeb.stylizer_rules = StylizerRules(self.opts, self.profile, stylesheets)
|
|
self.rules = self.oeb.stylizer_rules.rules
|
|
self.page_rule = self.oeb.stylizer_rules.page_rule
|
|
self.font_face_rules = self.oeb.stylizer_rules.font_face_rules
|
|
self.flatten_style = self.oeb.stylizer_rules.flatten_style
|
|
|
|
self._styles = {}
|
|
pseudo_pat = re.compile(':{1,2}(%s)' % ('|'.join(INAPPROPRIATE_PSEUDO_CLASSES)), re.I)
|
|
select = Select(tree, ignore_inappropriate_pseudo_classes=True)
|
|
|
|
for _, _, cssdict, text, _ in self.rules:
|
|
fl = pseudo_pat.search(text)
|
|
try:
|
|
matches = tuple(select(text))
|
|
except SelectorError as err:
|
|
self.logger.error('Ignoring CSS rule with invalid selector: %r (%s)' % (text, as_unicode(err)))
|
|
continue
|
|
|
|
if fl is not None:
|
|
fl = fl.group(1)
|
|
if fl == 'first-letter' and getattr(self.oeb,
|
|
'plumber_output_format', '').lower() in {'mobi', 'docx'}:
|
|
# Fake first-letter
|
|
for elem in matches:
|
|
for x in elem.iter('*'):
|
|
if x.text:
|
|
punctuation_chars = []
|
|
text = unicode_type(x.text)
|
|
while text:
|
|
category = unicodedata.category(text[0])
|
|
if category[0] not in {'P', 'Z'}:
|
|
break
|
|
punctuation_chars.append(text[0])
|
|
text = text[1:]
|
|
|
|
special_text = ''.join(punctuation_chars) + \
|
|
(text[0] if text else '')
|
|
span = x.makeelement('{%s}span' % XHTML_NS)
|
|
span.text = special_text
|
|
span.set('data-fake-first-letter', '1')
|
|
span.tail = text[1:]
|
|
x.text = None
|
|
x.insert(0, span)
|
|
self.style(span)._update_cssdict(cssdict)
|
|
break
|
|
else: # Element pseudo-class
|
|
for elem in matches:
|
|
self.style(elem)._update_pseudo_class(fl, cssdict)
|
|
else:
|
|
for elem in matches:
|
|
self.style(elem)._update_cssdict(cssdict)
|
|
for elem in xpath(tree, '//h:*[@style]'):
|
|
self.style(elem)._apply_style_attr(url_replacer=item.abshref)
|
|
num_pat = re.compile(r'[0-9.]+$')
|
|
for elem in xpath(tree, '//h:img[@width or @height]'):
|
|
style = self.style(elem)
|
|
# Check if either height or width is not default
|
|
is_styled = style._style.get('width', 'auto') != 'auto' or \
|
|
style._style.get('height', 'auto') != 'auto'
|
|
if not is_styled:
|
|
# Update img style dimension using width and height
|
|
upd = {}
|
|
for prop in ('width', 'height'):
|
|
val = elem.get(prop, '').strip()
|
|
try:
|
|
del elem.attrib[prop]
|
|
except:
|
|
pass
|
|
if val:
|
|
if num_pat.match(val) is not None:
|
|
val += 'px'
|
|
upd[prop] = val
|
|
if upd:
|
|
style._update_cssdict(upd)
|
|
|
|
def _fetch_css_file(self, path):
|
|
hrefs = self.oeb.manifest.hrefs
|
|
if path not in hrefs:
|
|
self.logger.warn('CSS import of missing file %r' % path)
|
|
return (None, None)
|
|
item = hrefs[path]
|
|
if item.media_type not in OEB_STYLES:
|
|
self.logger.warn('CSS import of non-CSS file %r' % path)
|
|
return (None, None)
|
|
data = item.data.cssText
|
|
if not isinstance(data, bytes):
|
|
data = data.encode('utf-8')
|
|
return ('utf-8', data)
|
|
|
|
def style(self, element):
|
|
try:
|
|
return self._styles[element]
|
|
except KeyError:
|
|
return Style(element, self)
|
|
|
|
def stylesheet(self, name, font_scale=None):
|
|
rules = []
|
|
for _, _, style, selector, href in self.rules:
|
|
if href != name:
|
|
continue
|
|
if font_scale and 'font-size' in style and \
|
|
style['font-size'].endswith('pt'):
|
|
style = copy.copy(style)
|
|
size = float(style['font-size'][:-2])
|
|
style['font-size'] = "%.2fpt" % (size * font_scale)
|
|
style = ';\n '.join(': '.join(item) for item in style.items())
|
|
rules.append('%s {\n %s;\n}' % (selector, style))
|
|
return '\n'.join(rules)
|
|
|
|
|
|
class Style(object):
|
|
MS_PAT = re.compile(r'^\s*(mso-|panose-|text-underline|tab-interval)')
|
|
|
|
def __init__(self, element, stylizer):
|
|
self._element = element
|
|
self._profile = stylizer.profile
|
|
self._stylizer = stylizer
|
|
self._style = {}
|
|
self._fontSize = None
|
|
self._width = None
|
|
self._height = None
|
|
self._lineHeight = None
|
|
self._bgcolor = None
|
|
self._pseudo_classes = {}
|
|
stylizer._styles[element] = self
|
|
|
|
def set(self, prop, val):
|
|
self._style[prop] = val
|
|
|
|
def drop(self, prop, default=None):
|
|
return self._style.pop(prop, default)
|
|
|
|
def _update_cssdict(self, cssdict):
|
|
self._style.update(cssdict)
|
|
|
|
def _update_pseudo_class(self, name, cssdict):
|
|
orig = self._pseudo_classes.get(name, {})
|
|
orig.update(cssdict)
|
|
self._pseudo_classes[name] = orig
|
|
|
|
def _apply_style_attr(self, url_replacer=None):
|
|
attrib = self._element.attrib
|
|
if 'style' not in attrib:
|
|
return
|
|
css = attrib['style'].split(';')
|
|
css = filter(None, (x.strip() for x in css))
|
|
css = [y.strip() for y in css]
|
|
css = [y for y in css if self.MS_PAT.match(y) is None]
|
|
css = '; '.join(css)
|
|
try:
|
|
style = parseStyle(css, validate=False)
|
|
except CSSSyntaxError:
|
|
return
|
|
if url_replacer is not None:
|
|
replaceUrls(style, url_replacer, ignoreImportRules=True)
|
|
self._style.update(self._stylizer.flatten_style(style))
|
|
|
|
def _has_parent(self):
|
|
try:
|
|
return self._element.getparent() is not None
|
|
except AttributeError:
|
|
return False # self._element is None
|
|
|
|
def _get_parent(self):
|
|
elem = self._element.getparent()
|
|
if elem is None:
|
|
return None
|
|
return self._stylizer.style(elem)
|
|
|
|
def __getitem__(self, name):
|
|
domname = cssproperties._toDOMname(name)
|
|
if hasattr(self, domname):
|
|
return getattr(self, domname)
|
|
return self._unit_convert(self._get(name))
|
|
|
|
def _get(self, name):
|
|
result = None
|
|
if name in self._style:
|
|
result = self._style[name]
|
|
if (result == 'inherit' or (result is None and name in INHERITED and self._has_parent())):
|
|
stylizer = self._stylizer
|
|
result = stylizer.style(self._element.getparent())._get(name)
|
|
if result is None:
|
|
result = DEFAULTS[name]
|
|
return result
|
|
|
|
def get(self, name, default=None):
|
|
return self._style.get(name, default)
|
|
|
|
def _unit_convert(self, value, base=None, font=None):
|
|
'Return value in pts'
|
|
if base is None:
|
|
base = self.width
|
|
if not font and font != 0:
|
|
font = self.fontSize
|
|
return unit_convert(value, base, font, self._profile.dpi, body_font_size=self._stylizer.body_font_size)
|
|
|
|
def pt_to_px(self, value):
|
|
return (self._profile.dpi / 72) * value
|
|
|
|
@property
|
|
def backgroundColor(self):
|
|
'''
|
|
Return the background color by parsing both the background-color and
|
|
background shortcut properties. Note that inheritance/default values
|
|
are not used. None is returned if no background color is set.
|
|
'''
|
|
|
|
def validate_color(col):
|
|
return cssprofiles.validateWithProfile('color',
|
|
col,
|
|
profiles=[profiles.Profiles.CSS_LEVEL_2])[1]
|
|
|
|
if self._bgcolor is None:
|
|
col = None
|
|
val = self._style.get('background-color', None)
|
|
if val and validate_color(val):
|
|
col = val
|
|
else:
|
|
val = self._style.get('background', None)
|
|
if val is not None:
|
|
try:
|
|
style = parseStyle('background: '+val, validate=False)
|
|
val = style.getProperty('background').cssValue
|
|
try:
|
|
val = list(val)
|
|
except:
|
|
# val is CSSPrimitiveValue
|
|
val = [val]
|
|
for c in val:
|
|
c = c.cssText
|
|
if isinstance(c, bytes):
|
|
c = c.decode('utf-8', 'replace')
|
|
if validate_color(c):
|
|
col = c
|
|
break
|
|
except:
|
|
pass
|
|
if col is None:
|
|
self._bgcolor = False
|
|
else:
|
|
self._bgcolor = col
|
|
return self._bgcolor if self._bgcolor else None
|
|
|
|
@property
|
|
def fontSize(self):
|
|
def normalize_fontsize(value, base):
|
|
value = value.replace('"', '').replace("'", '')
|
|
result = None
|
|
factor = None
|
|
if value == 'inherit':
|
|
value = base
|
|
if value in FONT_SIZE_NAMES:
|
|
result = self._profile.fnames[value]
|
|
elif value == 'smaller':
|
|
factor = 1.0/1.2
|
|
for _, _, size in self._profile.fsizes:
|
|
if base <= size:
|
|
break
|
|
factor = None
|
|
result = size
|
|
elif value == 'larger':
|
|
factor = 1.2
|
|
for _, _, size in reversed(self._profile.fsizes):
|
|
if base >= size:
|
|
break
|
|
factor = None
|
|
result = size
|
|
else:
|
|
result = self._unit_convert(value, base=base, font=base)
|
|
if not isinstance(result, numbers.Number):
|
|
return base
|
|
if result < 0:
|
|
result = normalize_fontsize("smaller", base)
|
|
if factor:
|
|
result = factor * base
|
|
return result
|
|
if self._fontSize is None:
|
|
result = None
|
|
parent = self._get_parent()
|
|
if parent is not None:
|
|
base = parent.fontSize
|
|
else:
|
|
base = self._profile.fbase
|
|
if 'font-size' in self._style:
|
|
size = self._style['font-size']
|
|
result = normalize_fontsize(size, base)
|
|
else:
|
|
result = base
|
|
self._fontSize = result
|
|
return self._fontSize
|
|
|
|
def img_dimension(self, attr, img_size):
|
|
ans = None
|
|
parent = self._get_parent()
|
|
if parent is not None:
|
|
base = getattr(parent, attr)
|
|
else:
|
|
base = getattr(self._profile, attr + '_pts')
|
|
x = self._style.get(attr)
|
|
if x is not None:
|
|
if x == 'auto':
|
|
ans = self._unit_convert(unicode_type(img_size) + 'px', base=base)
|
|
else:
|
|
x = self._unit_convert(x, base=base)
|
|
if isinstance(x, numbers.Number):
|
|
ans = x
|
|
if ans is None:
|
|
x = self._element.get(attr)
|
|
if x is not None:
|
|
x = self._unit_convert(x + 'px', base=base)
|
|
if isinstance(x, numbers.Number):
|
|
ans = x
|
|
if ans is None:
|
|
ans = self._unit_convert(unicode_type(img_size) + 'px', base=base)
|
|
maa = self._style.get('max-' + attr)
|
|
if maa is not None:
|
|
x = self._unit_convert(maa, base=base)
|
|
if isinstance(x, numbers.Number) and (ans is None or x < ans):
|
|
ans = x
|
|
return ans
|
|
|
|
def img_size(self, width, height):
|
|
' Return the final size of an <img> given that it points to an image of size widthxheight '
|
|
w, h = self._get('width'), self._get('height')
|
|
answ, ansh = self.img_dimension('width', width), self.img_dimension('height', height)
|
|
if w == 'auto' and h != 'auto':
|
|
answ = (float(width)/height) * ansh
|
|
elif h == 'auto' and w != 'auto':
|
|
ansh = (float(height)/width) * answ
|
|
return answ, ansh
|
|
|
|
@property
|
|
def width(self):
|
|
if self._width is None:
|
|
width = None
|
|
base = None
|
|
parent = self._get_parent()
|
|
if parent is not None:
|
|
base = parent.width
|
|
else:
|
|
base = self._profile.width_pts
|
|
if 'width' in self._element.attrib:
|
|
width = self._element.attrib['width']
|
|
elif 'width' in self._style:
|
|
width = self._style['width']
|
|
if not width or width == 'auto':
|
|
result = base
|
|
else:
|
|
result = self._unit_convert(width, base=base)
|
|
if isinstance(result, (unicode_type, bytes)):
|
|
result = self._profile.width
|
|
self._width = result
|
|
if 'max-width' in self._style:
|
|
result = self._unit_convert(self._style['max-width'], base=base)
|
|
if isinstance(result, (unicode_type, bytes)):
|
|
result = self._width
|
|
if result < self._width:
|
|
self._width = result
|
|
|
|
return self._width
|
|
|
|
@property
|
|
def parent_width(self):
|
|
parent = self._get_parent()
|
|
if parent is None:
|
|
return self.width
|
|
return parent.width
|
|
|
|
@property
|
|
def height(self):
|
|
if self._height is None:
|
|
height = None
|
|
base = None
|
|
parent = self._get_parent()
|
|
if parent is not None:
|
|
base = parent.height
|
|
else:
|
|
base = self._profile.height_pts
|
|
if 'height' in self._element.attrib:
|
|
height = self._element.attrib['height']
|
|
elif 'height' in self._style:
|
|
height = self._style['height']
|
|
if not height or height == 'auto':
|
|
result = base
|
|
else:
|
|
result = self._unit_convert(height, base=base)
|
|
if isinstance(result, (unicode_type, bytes)):
|
|
result = self._profile.height
|
|
self._height = result
|
|
if 'max-height' in self._style:
|
|
result = self._unit_convert(self._style['max-height'], base=base)
|
|
if isinstance(result, (unicode_type, bytes)):
|
|
result = self._height
|
|
if result < self._height:
|
|
self._height = result
|
|
|
|
return self._height
|
|
|
|
@property
|
|
def lineHeight(self):
|
|
if self._lineHeight is None:
|
|
result = None
|
|
parent = self._get_parent()
|
|
if 'line-height' in self._style:
|
|
lineh = self._style['line-height']
|
|
if lineh == 'normal':
|
|
lineh = '1.2'
|
|
try:
|
|
result = float(lineh) * self.fontSize
|
|
except ValueError:
|
|
result = self._unit_convert(lineh, base=self.fontSize)
|
|
elif parent is not None:
|
|
# TODO: proper inheritance
|
|
result = parent.lineHeight
|
|
else:
|
|
result = 1.2 * self.fontSize
|
|
self._lineHeight = result
|
|
return self._lineHeight
|
|
|
|
@property
|
|
def effective_text_decoration(self):
|
|
'''
|
|
Browsers do this creepy thing with text-decoration where even though the
|
|
property is not inherited, it looks like it is because containing
|
|
blocks apply it. The actual algorithm is utterly ridiculous, see
|
|
http://reference.sitepoint.com/css/text-decoration
|
|
This matters for MOBI output, where text-decoration is mapped to <u>
|
|
and <st> tags. Trying to implement the actual algorithm is too much
|
|
work, so we just use a simple fake that should cover most cases.
|
|
'''
|
|
css = self._style.get('text-decoration', None)
|
|
pcss = None
|
|
parent = self._get_parent()
|
|
if parent is not None:
|
|
pcss = parent._style.get('text-decoration', None)
|
|
if css in ('none', None, 'inherit') and pcss not in (None, 'none'):
|
|
return pcss
|
|
return css
|
|
|
|
@property
|
|
def first_vertical_align(self):
|
|
''' For docx output where tags are not nested, we cannot directly
|
|
simulate the HTML vertical-align rendering model. Instead use the
|
|
approximation of considering the first non-default vertical-align '''
|
|
val = self['vertical-align']
|
|
if val != 'baseline':
|
|
raw_val = self._get('vertical-align')
|
|
if '%' in raw_val:
|
|
val = self._unit_convert(raw_val, base=self['line-height'])
|
|
return val
|
|
parent = self._get_parent()
|
|
if parent is not None and 'inline' in parent['display']:
|
|
return parent.first_vertical_align
|
|
|
|
@property
|
|
def marginTop(self):
|
|
return self._unit_convert(
|
|
self._get('margin-top'), base=self.parent_width)
|
|
|
|
@property
|
|
def marginBottom(self):
|
|
return self._unit_convert(
|
|
self._get('margin-bottom'), base=self.parent_width)
|
|
|
|
@property
|
|
def marginLeft(self):
|
|
return self._unit_convert(
|
|
self._get('margin-left'), base=self.parent_width)
|
|
|
|
@property
|
|
def marginRight(self):
|
|
return self._unit_convert(
|
|
self._get('margin-right'), base=self.parent_width)
|
|
|
|
@property
|
|
def paddingTop(self):
|
|
return self._unit_convert(
|
|
self._get('padding-top'), base=self.parent_width)
|
|
|
|
@property
|
|
def paddingBottom(self):
|
|
return self._unit_convert(
|
|
self._get('padding-bottom'), base=self.parent_width)
|
|
|
|
@property
|
|
def paddingLeft(self):
|
|
return self._unit_convert(
|
|
self._get('padding-left'), base=self.parent_width)
|
|
|
|
@property
|
|
def paddingRight(self):
|
|
return self._unit_convert(
|
|
self._get('padding-right'), base=self.parent_width)
|
|
|
|
def __str__(self):
|
|
items = sorted(iteritems(self._style))
|
|
return '; '.join("%s: %s" % (key, val) for key, val in items)
|
|
|
|
def cssdict(self):
|
|
return dict(self._style)
|
|
|
|
def pseudo_classes(self, filter_css):
|
|
if filter_css:
|
|
css = copy.deepcopy(self._pseudo_classes)
|
|
for psel, cssdict in iteritems(css):
|
|
for k in filter_css:
|
|
cssdict.pop(k, None)
|
|
else:
|
|
css = self._pseudo_classes
|
|
return {k:v for k, v in iteritems(css) if v}
|
|
|
|
@property
|
|
def is_hidden(self):
|
|
return self._style.get('display') == 'none' or self._style.get('visibility') == 'hidden'
|