Conversion: Handle shorthand CSS properties

Conversion: Fix bugs in the handling of shorthand CSS properties
such as font or border, which could cause some properties to
not be resolved correctly during conversion.
This commit is contained in:
Kovid Goyal 2013-08-22 17:34:42 +05:30
parent 3d12ae2e9e
commit 5c1998610f
2 changed files with 280 additions and 141 deletions

View File

@ -0,0 +1,269 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
from future_builtins import zip
from functools import wraps
try:
from cssutils.css import PropertyValue
except ImportError:
raise RuntimeError('You need cssutils >= 0.9.9 for calibre')
from cssutils import profile as cssprofiles
DEFAULTS = {'azimuth': 'center', 'background-attachment': 'scroll', # {{{
'background-color': 'transparent', 'background-image': 'none',
'background-position': '0% 0%', 'background-repeat': 'repeat',
'border-bottom-color': 'currentColor', 'border-bottom-style': 'none',
'border-bottom-width': 'medium', 'border-collapse': 'separate',
'border-left-color': 'currentColor', 'border-left-style': 'none',
'border-left-width': 'medium', 'border-right-color': 'currentColor',
'border-right-style': 'none', 'border-right-width': 'medium',
'border-spacing': 0, 'border-top-color': 'currentColor',
'border-top-style': 'none', 'border-top-width': 'medium', 'bottom':
'auto', 'caption-side': 'top', 'clear': 'none', 'clip': 'auto',
'color': 'black', 'content': 'normal', 'counter-increment': 'none',
'counter-reset': 'none', 'cue-after': 'none', 'cue-before': 'none',
'cursor': 'auto', 'direction': 'ltr', 'display': 'inline',
'elevation': 'level', 'empty-cells': 'show', 'float': 'none',
'font-family': 'serif', 'font-size': 'medium', 'font-style':
'normal', 'font-variant': 'normal', 'font-weight': 'normal',
'height': 'auto', 'left': 'auto', 'letter-spacing': 'normal',
'line-height': 'normal', 'list-style-image': 'none',
'list-style-position': 'outside', 'list-style-type': 'disc',
'margin-bottom': 0, 'margin-left': 0, 'margin-right': 0,
'margin-top': 0, 'max-height': 'none', 'max-width': 'none',
'min-height': 0, 'min-width': 0, 'orphans': '2',
'outline-color': 'invert', 'outline-style': 'none',
'outline-width': 'medium', 'overflow': 'visible', 'padding-bottom':
0, 'padding-left': 0, 'padding-right': 0, 'padding-top': 0,
'page-break-after': 'auto', 'page-break-before': 'auto',
'page-break-inside': 'auto', 'pause-after': 0, 'pause-before':
0, 'pitch': 'medium', 'pitch-range': '50', 'play-during': 'auto',
'position': 'static', 'quotes': u"'' '' '' ''", 'richness':
'50', 'right': 'auto', 'speak': 'normal', 'speak-header': 'once',
'speak-numeral': 'continuous', 'speak-punctuation': 'none',
'speech-rate': 'medium', 'stress': '50', 'table-layout': 'auto',
'text-align': 'auto', 'text-decoration': 'none', 'text-indent':
0, 'text-transform': 'none', 'top': 'auto', 'unicode-bidi':
'normal', 'vertical-align': 'baseline', 'visibility': 'visible',
'voice-family': 'default', 'volume': 'medium', 'white-space':
'normal', 'widows': '2', 'width': 'auto', 'word-spacing': 'normal',
'z-index': 'auto'}
# }}}
EDGES = ('top', 'right', 'bottom', 'left')
def normalize_edge(name, cssvalue):
style = {}
if isinstance(cssvalue, PropertyValue):
primitives = [v.cssText for v in cssvalue]
else:
primitives = [cssvalue.cssText]
if len(primitives) == 1:
value, = primitives
values = (value, value, value, value)
elif len(primitives) == 2:
vert, horiz = primitives
values = (vert, horiz, vert, horiz)
elif len(primitives) == 3:
top, horiz, bottom = primitives
values = (top, horiz, bottom, horiz)
else:
values = primitives[:4]
if '-' in name:
l, _, r = name.partition('-')
for edge, value in zip(EDGES, values):
style['%s-%s-%s' % (l, edge, r)] = value
else:
for edge, value in zip(EDGES, values):
style['%s-%s' % (name, edge)] = value
return style
def simple_normalizer(prefix, names, check_inherit=True):
composition = tuple('%s-%s' %(prefix, n) for n in names)
@wraps(normalize_simple_composition)
def wrapper(name, cssvalue):
return normalize_simple_composition(name, cssvalue, composition, check_inherit=check_inherit)
return wrapper
def normalize_simple_composition(name, cssvalue, composition, check_inherit=True):
if check_inherit and cssvalue.cssText == 'inherit':
style = {k:'inherit' for k in composition}
else:
style = {k:DEFAULTS[k] for k in composition}
try:
primitives = [v.cssText for v in cssvalue]
except TypeError:
primitives = [cssvalue.cssText]
while primitives:
value = primitives.pop()
for key in composition:
if cssprofiles.validate(key, value):
style[key] = value
break
return style
font_composition = ('font-style', 'font-variant', 'font-weight', 'font-size', 'line-height', 'font-family')
def normalize_font(name, cssvalue):
# See https://developer.mozilla.org/en-US/docs/Web/CSS/font
composition = font_composition
val = cssvalue.cssText
if val == 'inherit':
return {k:'inherit' for k in composition}
if val in {'caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar'}:
return {k:DEFAULTS[k] for k in composition}
try:
primitives = list(v.cssText for v in cssvalue)
except TypeError:
primitives = [cssvalue.cssText]
if len(primitives) < 2:
return {} # Mandatory to define both font size and font family
style = {k:DEFAULTS[k] for k in composition}
style['font-family'] = primitives.pop()
if len(primitives) > 1 and cssprofiles.validate('line-height', primitives[-1]) and cssprofiles.validate('font-size', primitives[-2]):
style['line-height'], style['font-size'] = primitives.pop(), primitives.pop()
else:
val = primitives.pop()
if not cssprofiles.validate('font-size', val):
return {}
style['font-size'] = val
composition = composition[:3]
while primitives:
value = primitives.pop()
for key in composition:
if cssprofiles.validate(key, value):
style[key] = value
break
return style
def normalize_border(name, cssvalue):
style = normalizers['border-' + EDGES[0]]('border-' + EDGES[0], cssvalue)
vals = style.copy()
for edge in EDGES[1:]:
style.update({k.replace(EDGES[0], edge):v for k, v in vals.iteritems()})
return style
normalizers = {
'list-style': simple_normalizer('list-style', ('type', 'position', 'image')),
'font': normalize_font,
'border': normalize_border,
}
for x in ('margin', 'padding', 'border-style', 'border-width', 'border-color'):
normalizers[x] = normalize_edge
for x in EDGES:
name = 'border-' + x
normalizers[name] = simple_normalizer(name, ('color', 'style', 'width'), check_inherit=False)
def test_normalization():
import unittest
from cssutils import parseStyle
class TestNormalization(unittest.TestCase):
longMessage = True
maxDiff = None
def test_font_normalization(self):
def font_dict(expected):
ans = {k:DEFAULTS[k] for k in font_composition}
ans.update(expected)
return ans
for raw, expected in {
'some_font': {}, 'none': {}, 'inherit':{k:'inherit' for k in font_composition},
'1.2pt/1.4 A_Font': {'font-family':'A_Font', 'font-size':'1.2pt', 'line-height':'1.4'},
'bad font': {}, '10% serif': {'font-family':'serif', 'font-size':'10%'},
'bold italic large serif': {'font-family':'serif', 'font-weight':'bold', 'font-style':'italic', 'font-size':'large'},
'bold italic small-caps larger/normal serif':
{'font-family':'serif', 'font-weight':'bold', 'font-style':'italic', 'font-size':'larger',
'line-height':'normal', 'font-variant':'small-caps'},
}.iteritems():
val = tuple(parseStyle('font: %s' % raw, validate=False))[0].cssValue
style = normalizers['font']('font', val)
if expected:
self.assertDictEqual(font_dict(expected), style)
else:
self.assertDictEqual(expected, style)
def test_border_normalization(self):
def border_edge_dict(expected, edge='right'):
ans = {'border-%s-%s' % (edge, x): DEFAULTS['border-%s-%s' % (edge, x)] for x in ('style', 'width', 'color')}
for x, v in expected.iteritems():
ans['border-%s-%s' % (edge, x)] = v
return ans
def border_dict(expected):
ans = {}
for edge in EDGES:
ans.update(border_edge_dict(expected, edge))
return ans
def border_val_dict(expected, val='color'):
ans = {'border-%s-%s' % (edge, val): DEFAULTS['border-%s-%s' % (edge, val)] for edge in EDGES}
for edge in EDGES:
ans['border-%s-%s' % (edge, val)] = expected
return ans
for raw, expected in {
'solid 1px red': {'color':'red', 'width':'1px', 'style':'solid'},
'1px': {'width': '1px'}, '#aaa': {'color': '#aaa'},
'2em groove': {'width':'2em', 'style':'groove'},
}.iteritems():
for edge in EDGES:
br = 'border-%s' % edge
val = tuple(parseStyle('%s: %s' % (br, raw), validate=False))[0].cssValue
self.assertDictEqual(border_edge_dict(expected, edge), normalizers[br](br, val))
for raw, expected in {
'solid 1px red': {'color':'red', 'width':'1px', 'style':'solid'},
'1px': {'width': '1px'}, '#aaa': {'color': '#aaa'},
'thin groove': {'width':'thin', 'style':'groove'},
}.iteritems():
val = tuple(parseStyle('%s: %s' % ('border', raw), validate=False))[0].cssValue
self.assertDictEqual(border_dict(expected), normalizers['border']('border', val))
for name, val in {
'width': '10%', 'color': 'rgb(0, 1, 1)', 'style': 'double',
}.iteritems():
cval = tuple(parseStyle('border-%s: %s' % (name, val), validate=False))[0].cssValue
self.assertDictEqual(border_val_dict(val, name), normalizers['border-'+name]('border-'+name, cval))
def test_edge_normalization(self):
def edge_dict(prefix, expected):
return {'%s-%s' % (prefix, edge) : x for edge, x in zip(EDGES, expected)}
for raw, expected in {
'2px': ('2px', '2px', '2px', '2px'),
'1em 2em': ('1em', '2em', '1em', '2em'),
'1em 2em 3em': ('1em', '2em', '3em', '2em'),
'1 2 3 4': ('1', '2', '3', '4'),
}.iteritems():
for prefix in ('margin', 'padding'):
cval = tuple(parseStyle('%s: %s' % (prefix, raw), validate=False))[0].cssValue
self.assertDictEqual(edge_dict(prefix, expected), normalizers[prefix](prefix, cval))
def test_list_style_normalization(self):
def ls_dict(expected):
ans = {'list-style-%s' % x : DEFAULTS['list-style-%s' % x] for x in ('type', 'image', 'position')}
for k, v in expected.iteritems():
ans['list-style-%s' % k] = v
return ans
for raw, expected in {
'url(http://www.example.com/images/list.png)': {'image': 'url(http://www.example.com/images/list.png)'},
'inside square': {'position':'inside', 'type':'square'},
'upper-roman url(img) outside': {'position':'outside', 'type':'upper-roman', 'image':'url(img)'},
}.iteritems():
cval = tuple(parseStyle('list-style: %s' % raw, validate=False))[0].cssValue
self.assertDictEqual(ls_dict(expected), normalizers['list-style']('list-style', cval))
tests = unittest.defaultTestLoader.loadTestsFromTestCase(TestNormalization)
unittest.TextTestRunner(verbosity=4).run(tests)
if __name__ == '__main__':
test_normalization()

View File

@ -8,15 +8,11 @@ from __future__ import with_statement
__license__ = 'GPL v3'
__copyright__ = '2008, Marshall T. Vandegrift <llasram@gmail.com>'
import os, itertools, re, logging, copy, unicodedata
import os, re, logging, copy, unicodedata
from weakref import WeakKeyDictionary
from xml.dom import SyntaxErr as CSSSyntaxError
from cssutils.css import (CSSStyleRule, CSSPageRule, CSSFontFaceRule,
cssproperties)
try:
from cssutils.css import PropertyValue
except ImportError:
raise RuntimeError('You need cssutils >= 0.9.9 for calibre')
from cssutils import (profile as cssprofiles, parseString, parseStyle, log as
cssutils_log, CSSParser, profiles, replaceUrls)
from lxml import etree
@ -24,8 +20,8 @@ from cssselect import HTMLTranslator
from calibre import force_unicode
from calibre.ebooks import unit_convert
from calibre.ebooks.oeb.base import XHTML, XHTML_NS, CSS_MIME, OEB_STYLES
from calibre.ebooks.oeb.base import XPNSMAP, xpath, urlnormalize
from calibre.ebooks.oeb.base import XHTML, XHTML_NS, CSS_MIME, OEB_STYLES, XPNSMAP, xpath, urlnormalize
from calibre.ebooks.oeb.normalize_css import DEFAULTS, normalizers
cssutils_log.setLevel(logging.WARN)
@ -54,46 +50,6 @@ INHERITED = set(['azimuth', 'border-collapse', 'border-spacing',
'visibility', 'voice-family', 'volume', 'white-space',
'widows', 'word-spacing'])
DEFAULTS = {'azimuth': 'center', 'background-attachment': 'scroll',
'background-color': 'transparent', 'background-image': 'none',
'background-position': '0% 0%', 'background-repeat': 'repeat',
'border-bottom-color': ':color', 'border-bottom-style': 'none',
'border-bottom-width': 'medium', 'border-collapse': 'separate',
'border-left-color': ':color', 'border-left-style': 'none',
'border-left-width': 'medium', 'border-right-color': ':color',
'border-right-style': 'none', 'border-right-width': 'medium',
'border-spacing': 0, 'border-top-color': ':color',
'border-top-style': 'none', 'border-top-width': 'medium', 'bottom':
'auto', 'caption-side': 'top', 'clear': 'none', 'clip': 'auto',
'color': 'black', 'content': 'normal', 'counter-increment': 'none',
'counter-reset': 'none', 'cue-after': 'none', 'cue-before': 'none',
'cursor': 'auto', 'direction': 'ltr', 'display': 'inline',
'elevation': 'level', 'empty-cells': 'show', 'float': 'none',
'font-family': 'serif', 'font-size': 'medium', 'font-style':
'normal', 'font-variant': 'normal', 'font-weight': 'normal',
'height': 'auto', 'left': 'auto', 'letter-spacing': 'normal',
'line-height': 'normal', 'list-style-image': 'none',
'list-style-position': 'outside', 'list-style-type': 'disc',
'margin-bottom': 0, 'margin-left': 0, 'margin-right': 0,
'margin-top': 0, 'max-height': 'none', 'max-width': 'none',
'min-height': 0, 'min-width': 0, 'orphans': '2',
'outline-color': 'invert', 'outline-style': 'none',
'outline-width': 'medium', 'overflow': 'visible', 'padding-bottom':
0, 'padding-left': 0, 'padding-right': 0, 'padding-top': 0,
'page-break-after': 'auto', 'page-break-before': 'auto',
'page-break-inside': 'auto', 'pause-after': 0, 'pause-before':
0, 'pitch': 'medium', 'pitch-range': '50', 'play-during': 'auto',
'position': 'static', 'quotes': u"'' '' '' ''", 'richness':
'50', 'right': 'auto', 'speak': 'normal', 'speak-header': 'once',
'speak-numeral': 'continuous', 'speak-punctuation': 'none',
'speech-rate': 'medium', 'stress': '50', 'table-layout': 'auto',
'text-align': 'auto', 'text-decoration': 'none', 'text-indent':
0, 'text-transform': 'none', 'top': 'auto', 'unicode-bidi':
'normal', 'vertical-align': 'baseline', 'visibility': 'visible',
'voice-family': 'default', 'volume': 'medium', 'white-space':
'normal', 'widows': '2', 'width': 'auto', 'word-spacing': 'normal',
'z-index': 'auto'}
FONT_SIZE_NAMES = set(['xx-small', 'x-small', 'small', 'medium', 'large',
'x-large', 'xx-large'])
@ -421,14 +377,11 @@ class Stylizer(object):
style = {}
for prop in cssstyle:
name = prop.name
if name in ('margin', 'padding'):
style.update(self._normalize_edge(prop.cssValue, name))
elif name == 'font':
style.update(self._normalize_font(prop.cssValue))
elif name == 'list-style':
style.update(self._normalize_list_style(prop.cssValue))
normalizer = normalizers.get(name, None)
if normalizer is not None:
style.update(normalizer(name, prop.cssValue))
elif name == 'text-align':
style.update(self._normalize_text_align(prop.cssValue))
style['text-align'] = self._apply_text_align(prop.value)
else:
style[name] = prop.value
if 'font-size' in style:
@ -441,93 +394,10 @@ class Stylizer(object):
style['font-size'] = "%dpt" % self.profile.fnames[size]
return style
def _normalize_edge(self, cssvalue, name):
style = {}
if isinstance(cssvalue, PropertyValue):
primitives = [v.cssText for v in cssvalue]
else:
primitives = [cssvalue.cssText]
if len(primitives) == 1:
value, = primitives
values = [value, value, value, value]
elif len(primitives) == 2:
vert, horiz = primitives
values = [vert, horiz, vert, horiz]
elif len(primitives) == 3:
top, horiz, bottom = primitives
values = [top, horiz, bottom, horiz]
else:
values = primitives[:4]
edges = ('top', 'right', 'bottom', 'left')
for edge, value in itertools.izip(edges, values):
style["%s-%s" % (name, edge)] = value
return style
def _normalize_list_style(self, cssvalue):
composition = ('list-style-type', 'list-style-position',
'list-style-image')
style = {}
if cssvalue.cssText == 'inherit':
for key in composition:
style[key] = 'inherit'
else:
try:
primitives = [v.cssText for v in cssvalue]
except TypeError:
primitives = [cssvalue.cssText]
primitives.reverse()
value = primitives.pop()
for key in composition:
if cssprofiles.validate(key, value):
style[key] = value
if not primitives:
break
value = primitives.pop()
for key in composition:
if key not in style:
style[key] = DEFAULTS[key]
return style
def _normalize_text_align(self, cssvalue):
style = {}
text = cssvalue.cssText
if text == 'inherit':
style['text-align'] = 'inherit'
else:
def _apply_text_align(self, text):
if text in ('left', 'justify') and self.opts.change_justification in ('left', 'justify'):
val = self.opts.change_justification
style['text-align'] = val
else:
style['text-align'] = text
return style
def _normalize_font(self, cssvalue):
composition = ('font-style', 'font-variant', 'font-weight',
'font-size', 'line-height', 'font-family')
style = {}
if cssvalue.cssText == 'inherit':
for key in composition:
style[key] = 'inherit'
else:
try:
primitives = [v.cssText for v in cssvalue]
except TypeError:
primitives = [cssvalue.cssText]
primitives.reverse()
value = primitives.pop()
for key in composition:
if cssprofiles.validate(key, value):
style[key] = value
if not primitives:
break
value = primitives.pop()
for key in composition:
if key not in style:
val = ('inherit' if key in {'font-family', 'font-size'}
else 'normal')
style[key] = val
return style
text = self.opts.change_justification
return text
def style(self, element):
try: