Merge branch 'normalize'

EPUB/AZX3 Output: Use shorthand forms for margin, padding and
border CSS properties

Conversion: Fix a bug that could cause incorrect border values to be
used when cascading, shorthand border CSS is present.
This commit is contained in:
Kovid Goyal 2013-09-15 17:07:32 +05:30
commit 53b752fb4a
5 changed files with 417 additions and 156 deletions

View File

@ -148,7 +148,6 @@ class MOBIOutput(OutputFormatPlugin):
self.oeb.manifest.remove(self.oeb.manifest.hrefs[x.href]) self.oeb.manifest.remove(self.oeb.manifest.hrefs[x.href])
x.href = articles_[0].href x.href = articles_[0].href
for sec in sections: for sec in sections:
articles[id(sec)] = [] articles[id(sec)] = []
for a in list(sec): for a in list(sec):
@ -179,7 +178,7 @@ class MOBIOutput(OutputFormatPlugin):
mobi_type = opts.mobi_file_type mobi_type = opts.mobi_file_type
if self.is_periodical: if self.is_periodical:
mobi_type = 'old' # Amazon does not support KF8 periodicals mobi_type = 'old' # Amazon does not support KF8 periodicals
create_kf8 = mobi_type in ('new', 'both') create_kf8 = mobi_type in ('new', 'both')
remove_html_cover(self.oeb, self.log) remove_html_cover(self.oeb, self.log)
@ -188,12 +187,10 @@ class MOBIOutput(OutputFormatPlugin):
self.check_for_periodical() self.check_for_periodical()
if create_kf8: if create_kf8:
# Split on pagebreaks so that the resulting KF8 works better with # Split on pagebreaks so that the resulting KF8 is faster to load
# calibre's viewer, which does not support CSS page breaks
from calibre.ebooks.oeb.transforms.split import Split from calibre.ebooks.oeb.transforms.split import Split
Split()(self.oeb, self.opts) Split()(self.oeb, self.opts)
kf8 = self.create_kf8(resources, for_joint=mobi_type=='both' kf8 = self.create_kf8(resources, for_joint=mobi_type=='both'
) if create_kf8 else None ) if create_kf8 else None
if mobi_type == 'new': if mobi_type == 'new':

View File

@ -18,7 +18,6 @@ class OEBOutput(OutputFormatPlugin):
recommendations = set([('pretty_print', True, OptionRecommendation.HIGH)]) recommendations = set([('pretty_print', True, OptionRecommendation.HIGH)])
def convert(self, oeb_book, output_path, input_plugin, opts, log): def convert(self, oeb_book, output_path, input_plugin, opts, log):
from urllib import unquote from urllib import unquote
from lxml import etree from lxml import etree
@ -26,7 +25,8 @@ class OEBOutput(OutputFormatPlugin):
self.log, self.opts = log, opts self.log, self.opts = log, opts
if not os.path.exists(output_path): if not os.path.exists(output_path):
os.makedirs(output_path) os.makedirs(output_path)
from calibre.ebooks.oeb.base import OPF_MIME, NCX_MIME, PAGE_MAP_MIME from calibre.ebooks.oeb.base import OPF_MIME, NCX_MIME, PAGE_MAP_MIME, OEB_STYLES
from calibre.ebooks.oeb.normalize_css import condense_sheet
with CurrentDir(output_path): with CurrentDir(output_path):
results = oeb_book.to_opf2(page_map=True) results = oeb_book.to_opf2(page_map=True)
for key in (OPF_MIME, NCX_MIME, PAGE_MAP_MIME): for key in (OPF_MIME, NCX_MIME, PAGE_MAP_MIME):
@ -54,6 +54,8 @@ class OEBOutput(OutputFormatPlugin):
f.write(raw) f.write(raw)
for item in oeb_book.manifest: for item in oeb_book.manifest:
if item.media_type in OEB_STYLES and hasattr(item.data, 'cssText'):
condense_sheet(item.data)
path = os.path.abspath(unquote(item.href)) path = os.path.abspath(unquote(item.href))
dir = os.path.dirname(path) dir = os.path.dirname(path)
if not os.path.exists(dir): if not os.path.exists(dir):
@ -62,7 +64,7 @@ class OEBOutput(OutputFormatPlugin):
f.write(str(item)) f.write(str(item))
item.unload_data_from_memory(memory=path) item.unload_data_from_memory(memory=path)
def workaround_nook_cover_bug(self, root): # {{{ def workaround_nook_cover_bug(self, root): # {{{
cov = root.xpath('//*[local-name() = "meta" and @name="cover" and' cov = root.xpath('//*[local-name() = "meta" and @name="cover" and'
' @content != "cover"]') ' @content != "cover"]')
@ -96,7 +98,7 @@ class OEBOutput(OutputFormatPlugin):
cov.set('content', 'cover') cov.set('content', 'cover')
# }}} # }}}
def workaround_pocketbook_cover_bug(self, root): # {{{ def workaround_pocketbook_cover_bug(self, root): # {{{
m = root.xpath('//*[local-name() = "manifest"]/*[local-name() = "item" ' m = root.xpath('//*[local-name() = "manifest"]/*[local-name() = "item" '
' and @id="cover"]') ' and @id="cover"]')
if len(m) == 1: if len(m) == 1:
@ -106,7 +108,7 @@ class OEBOutput(OutputFormatPlugin):
p.insert(0, m) p.insert(0, m)
# }}} # }}}
def migrate_lang_code(self, root): # {{{ def migrate_lang_code(self, root): # {{{
from calibre.utils.localization import lang_as_iso639_1 from calibre.utils.localization import lang_as_iso639_1
for lang in root.xpath('//*[local-name() = "language"]'): for lang in root.xpath('//*[local-name() = "language"]'):
clc = lang_as_iso639_1(lang.text) clc = lang_as_iso639_1(lang.text)

View File

@ -22,6 +22,7 @@ from calibre.ebooks.mobi.utils import (create_text_record, to_base,
from calibre.ebooks.compression.palmdoc import compress_doc from calibre.ebooks.compression.palmdoc import compress_doc
from calibre.ebooks.oeb.base import (OEB_DOCS, OEB_STYLES, SVG_MIME, XPath, from calibre.ebooks.oeb.base import (OEB_DOCS, OEB_STYLES, SVG_MIME, XPath,
extract, XHTML, urlnormalize) extract, XHTML, urlnormalize)
from calibre.ebooks.oeb.normalize_css import condense_sheet
from calibre.ebooks.oeb.parse_utils import barename from calibre.ebooks.oeb.parse_utils import barename
from calibre.ebooks.mobi.writer8.skeleton import Chunker, aid_able_tags, to_href from calibre.ebooks.mobi.writer8.skeleton import Chunker, aid_able_tags, to_href
from calibre.ebooks.mobi.writer8.index import (NCXIndex, SkelIndex, from calibre.ebooks.mobi.writer8.index import (NCXIndex, SkelIndex,
@ -48,8 +49,8 @@ class KF8Writer(object):
self.toc_adder = TOCAdder(oeb, opts) self.toc_adder = TOCAdder(oeb, opts)
self.used_images = set() self.used_images = set()
self.resources = resources self.resources = resources
self.flows = [None] # First flow item is reserved for the text self.flows = [None] # First flow item is reserved for the text
self.records = [None] # Placeholder for zeroth record self.records = [None] # Placeholder for zeroth record
self.log('\tGenerating KF8 markup...') self.log('\tGenerating KF8 markup...')
self.dup_data() self.dup_data()
@ -145,11 +146,13 @@ class KF8Writer(object):
cssutils.replaceUrls(sheet, replacer, ignoreImportRules=True) cssutils.replaceUrls(sheet, replacer, ignoreImportRules=True)
def extract_css_into_flows(self): def extract_css_into_flows(self):
inlines = defaultdict(list) # Ensure identical <style>s not repeated inlines = defaultdict(list) # Ensure identical <style>s not repeated
sheets = {} sheets = {}
for item in self.oeb.manifest: for item in self.oeb.manifest:
if item.media_type in OEB_STYLES: if item.media_type in OEB_STYLES:
if hasattr(item.data, 'cssText'):
condense_sheet(self.data(item))
data = self.data(item).cssText data = self.data(item).cssText
sheets[item.href] = len(self.flows) sheets[item.href] = len(self.flows)
self.flows.append(force_unicode(data, 'utf-8')) self.flows.append(force_unicode(data, 'utf-8'))
@ -375,7 +378,7 @@ class KF8Writer(object):
key=lambda entry: (entry['depth'], entry['offset'])) key=lambda entry: (entry['depth'], entry['offset']))
is_non_linear = original != linearized is_non_linear = original != linearized
entries = linearized entries = linearized
is_non_linear = False # False as we are using the linearized entries is_non_linear = False # False as we are using the linearized entries
if is_non_linear: if is_non_linear:
for entry in entries: for entry in entries:
@ -428,7 +431,7 @@ class KF8Writer(object):
_('Unknown'), ref.type, (pos, fid))) _('Unknown'), ref.type, (pos, fid)))
if self.guide_table: if self.guide_table:
self.guide_table.sort(key=lambda x:x.type) # Needed by the Kindle self.guide_table.sort(key=lambda x:x.type) # Needed by the Kindle
self.guide_records = GuideIndex(self.guide_table)() self.guide_records = GuideIndex(self.guide_table)()
def create_kf8_book(oeb, opts, resources, for_joint=False): def create_kf8_book(oeb, opts, resources, for_joint=False):

View File

@ -0,0 +1,389 @@
#!/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')
BORDER_PROPS = ('color', 'style', 'width')
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, BORDER_PROPS, check_inherit=False)
def condense_edge(vals):
edges = {x.name.rpartition('-')[-1]:x.value for x in vals}
if len(edges) != 4:
return
ce = {}
for (x, y) in [('left', 'right'), ('top', 'bottom')]:
if edges[x] == edges[y]:
ce[x] = edges[x]
else:
ce[x], ce[y] = edges[x], edges[y]
if len(ce) == 4:
return ' '.join(ce[x] for x in ('top', 'right', 'bottom', 'left'))
if len(ce) == 3:
if 'right' in ce:
return ' '.join(ce[x] for x in ('top', 'right', 'top', 'left'))
return ' '.join(ce[x] for x in ('top', 'left', 'bottom'))
if len(ce) == 2:
if ce['top'] == ce['left']:
return ce['top']
return ' '.join(ce[x] for x in ('top', 'left'))
def simple_condenser(prefix, func):
@wraps(func)
def condense_simple(style, props):
cp = func(props)
if cp is not None:
for prop in props:
style.removeProperty(prop.name)
style.setProperty(prefix, cp)
return condense_simple
def condense_border(style, props):
prop_map = {p.name:p for p in props}
edge_vals = []
for edge in EDGES:
name = 'border-%s' % edge
vals = []
for prop in BORDER_PROPS:
x = prop_map.get('%s-%s' % (name, prop), None)
if x is not None:
vals.append(x)
if len(vals) == 3:
for prop in vals:
style.removeProperty(prop.name)
style.setProperty(name, ' '.join(x.value for x in vals))
prop_map[name] = style.getProperty(name)
x = prop_map.get(name, None)
if x is not None:
edge_vals.append(x)
if len(edge_vals) == 4 and len({x.value for x in edge_vals}) == 1:
for prop in edge_vals:
style.removeProperty(prop.name)
style.setProperty('border', edge_vals[0].value)
condensers = {'margin': simple_condenser('margin', condense_edge), 'padding': simple_condenser('padding', condense_edge), 'border': condense_border}
def condense_rule(style):
expanded = {'margin-':[], 'padding-':[], 'border-':[]}
for prop in style.getProperties():
for x in expanded:
if prop.name and prop.name.startswith(x):
expanded[x].append(prop)
break
for prefix, vals in expanded.iteritems():
if len(vals) > 1 and {x.priority for x in vals} == {''}:
condensers[prefix[:-1]](style, vals)
def condense_sheet(sheet):
for rule in sheet.cssRules:
if rule.type == rule.STYLE_RULE:
condense_rule(rule.style)
def test_normalization(): # {{{
import unittest
from cssutils import parseStyle
from itertools import product
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))
def test_edge_condensation(self):
for s, v in {
(1, 1, 3) : None,
(1, 2, 3, 4) : '2pt 3pt 4pt 1pt',
(1, 2, 3, 2) : '2pt 3pt 2pt 1pt',
(1, 2, 1, 3) : '2pt 1pt 3pt',
(1, 2, 1, 2) : '2pt 1pt',
(1, 1, 1, 1) : '1pt',
('2%', '2%', '2%', '2%') : '2%',
tuple('0 0 0 0'.split()) : '0',
}.iteritems():
for prefix in ('margin', 'padding'):
css = {'%s-%s' % (prefix, x) : str(y)+'pt' if isinstance(y, (int, float)) else y for x, y in zip(('left', 'top', 'right', 'bottom'), s)}
css = '; '.join(('%s:%s' % (k, v) for k, v in css.iteritems()))
style = parseStyle(css)
condense_rule(style)
val = getattr(style.getProperty(prefix), 'value', None)
self.assertEqual(v, val)
if val is not None:
for edge in EDGES:
self.assertFalse(getattr(style.getProperty('%s-%s' % (prefix, edge)), 'value', None))
def test_border_condensation(self):
vals = 'red solid 5px'
css = '; '.join('border-%s-%s: %s' % (edge, p, v) for edge in EDGES for p, v in zip(BORDER_PROPS, vals.split()))
style = parseStyle(css)
condense_rule(style)
for e, p in product(EDGES, BORDER_PROPS):
self.assertFalse(style.getProperty('border-%s-%s' % (e, p)))
self.assertFalse(style.getProperty('border-%s' % e))
self.assertFalse(style.getProperty('border-%s' % p))
self.assertEqual(style.getProperty('border').value, vals)
css = '; '.join('border-%s-%s: %s' % (edge, p, v) for edge in ('top',) for p, v in zip(BORDER_PROPS, vals.split()))
style = parseStyle(css)
condense_rule(style)
self.assertEqual(style.cssText, 'border-top: %s' % vals)
css += ';' + '; '.join('border-%s-%s: %s' % (edge, p, v) for edge in ('right', 'left', 'bottom') for p, v in
zip(BORDER_PROPS, vals.replace('red', 'green').split()))
style = parseStyle(css)
condense_rule(style)
self.assertEqual(len(style.getProperties()), 4)
self.assertEqual(style.getProperty('border-top').value, vals)
self.assertEqual(style.getProperty('border-left').value, vals.replace('red', 'green'))
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' __license__ = 'GPL v3'
__copyright__ = '2008, Marshall T. Vandegrift <llasram@gmail.com>' __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 weakref import WeakKeyDictionary
from xml.dom import SyntaxErr as CSSSyntaxError from xml.dom import SyntaxErr as CSSSyntaxError
from cssutils.css import (CSSStyleRule, CSSPageRule, CSSFontFaceRule, from cssutils.css import (CSSStyleRule, CSSPageRule, CSSFontFaceRule,
cssproperties) 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 from cssutils import (profile as cssprofiles, parseString, parseStyle, log as
cssutils_log, CSSParser, profiles, replaceUrls) cssutils_log, CSSParser, profiles, replaceUrls)
from lxml import etree from lxml import etree
@ -24,8 +20,8 @@ from cssselect import HTMLTranslator
from calibre import force_unicode from calibre import force_unicode
from calibre.ebooks import unit_convert 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 XHTML, XHTML_NS, CSS_MIME, OEB_STYLES, XPNSMAP, xpath, urlnormalize
from calibre.ebooks.oeb.base import XPNSMAP, xpath, urlnormalize from calibre.ebooks.oeb.normalize_css import DEFAULTS, normalizers
cssutils_log.setLevel(logging.WARN) cssutils_log.setLevel(logging.WARN)
@ -54,46 +50,6 @@ INHERITED = set(['azimuth', 'border-collapse', 'border-spacing',
'visibility', 'voice-family', 'volume', 'white-space', 'visibility', 'voice-family', 'volume', 'white-space',
'widows', 'word-spacing']) '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', FONT_SIZE_NAMES = set(['xx-small', 'x-small', 'small', 'medium', 'large',
'x-large', 'xx-large']) 'x-large', 'xx-large'])
@ -421,14 +377,11 @@ class Stylizer(object):
style = {} style = {}
for prop in cssstyle: for prop in cssstyle:
name = prop.name name = prop.name
if name in ('margin', 'padding'): normalizer = normalizers.get(name, None)
style.update(self._normalize_edge(prop.cssValue, name)) if normalizer is not None:
elif name == 'font': style.update(normalizer(name, prop.cssValue))
style.update(self._normalize_font(prop.cssValue))
elif name == 'list-style':
style.update(self._normalize_list_style(prop.cssValue))
elif name == 'text-align': elif name == 'text-align':
style.update(self._normalize_text_align(prop.cssValue)) style['text-align'] = self._apply_text_align(prop.value)
else: else:
style[name] = prop.value style[name] = prop.value
if 'font-size' in style: if 'font-size' in style:
@ -441,93 +394,10 @@ class Stylizer(object):
style['font-size'] = "%dpt" % self.profile.fnames[size] style['font-size'] = "%dpt" % self.profile.fnames[size]
return style return style
def _normalize_edge(self, cssvalue, name): def _apply_text_align(self, text):
style = {} if text in ('left', 'justify') and self.opts.change_justification in ('left', 'justify'):
if isinstance(cssvalue, PropertyValue): text = self.opts.change_justification
primitives = [v.cssText for v in cssvalue] return text
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:
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
def style(self, element): def style(self, element):
try: try: