Implement mapping of CSS to DOCX for run styles

Also switch to CSS 3 color parsing for run colors
This commit is contained in:
Kovid Goyal 2015-02-15 18:15:53 +05:30
parent 4e8f148e59
commit 957ce7604e
2 changed files with 115 additions and 72 deletions

View File

@ -16,7 +16,9 @@ from calibre.ebooks.docx.names import namespaces
from calibre.ebooks.docx.writer.utils import convert_color, int_or_zero from calibre.ebooks.docx.writer.utils import convert_color, int_or_zero
from calibre.ebooks.oeb.stylizer import Stylizer as Sz, Style as St from calibre.ebooks.oeb.stylizer import Stylizer as Sz, Style as St
from calibre.ebooks.oeb.base import XPath, barename from calibre.ebooks.oeb.base import XPath, barename
from tinycss.color3 import parse_color_string from tinycss.css21 import CSS21Parser
css_parser = CSS21Parser()
class Style(St): class Style(St):
@ -45,17 +47,20 @@ class Stylizer(Sz):
border_edges = ('left', 'top', 'right', 'bottom') border_edges = ('left', 'top', 'right', 'bottom')
border_props = ('padding_%s', 'border_%s_width', 'border_%s_style', 'border_%s_color') border_props = ('padding_%s', 'border_%s_width', 'border_%s_style', 'border_%s_color')
def css_color_to_rgb(value): def parse_css_font_family(raw):
if not value: decl, errs = css_parser.parse_style_attr('font-family:' + raw)
return if decl:
if value.lower() == 'currentcolor': for token in decl[0].value:
return 'auto' if token.type in 'STRING IDENT':
val = parse_color_string(value) val = token.value
if val is None: if val == 'inherit':
return break
if val.alpha < 0.01: yield val
return
return '%02X%02X%02X' % (int(val.red * 255), int(val.green * 255), int(val.blue * 255)) def css_font_family_to_docx(raw):
generic = {'serif':'Cambria', 'sansserif':'Candara', 'sans-serif':'Candara', 'fantasy':'Comic Sans', 'cursive':'Segoe Script'}
for ff in parse_css_font_family(raw):
return generic.get(ff.lower(), ff)
class DOCXStyle(object): class DOCXStyle(object):
@ -78,9 +83,25 @@ class DOCXStyle(object):
return not self == other return not self == other
def __repr__(self): def __repr__(self):
return etree.tostring(self.serialize(etree.Element(w('style'), nsmap={'w':namespaces['w']})), pretty_print=True) return etree.tostring(self.serialize(etree.Element(self.__class__.__name__, nsmap={'w':namespaces['w']})), pretty_print=True)
__str__ = __repr__ __str__ = __repr__
def serialize_borders(self, bdr):
for edge in border_edges:
e = bdr.makeelement(w(edge))
padding = getattr(self, 'padding_' + edge)
if padding > 0:
e.set(w('space'), str(padding))
width = getattr(self, 'border_%s_width' % edge)
bstyle = getattr(self, 'border_%s_style' % edge)
if width > 0 and bstyle != 'none':
e.set(w('val'), bstyle)
e.set(w('sz'), str(width))
e.set(w('color'), getattr(self, 'border_%s_color' % edge))
if e.attrib:
bdr.append(e)
return bdr
LINE_STYLES = { LINE_STYLES = {
'none': 'none', 'none': 'none',
'hidden': 'none', 'hidden': 'none',
@ -101,19 +122,19 @@ class TextStyle(DOCXStyle):
ALL_PROPS = ('font_family', 'font_size', 'bold', 'italic', 'color', ALL_PROPS = ('font_family', 'font_size', 'bold', 'italic', 'color',
'background_color', 'underline', 'strike', 'dstrike', 'caps', 'background_color', 'underline', 'strike', 'dstrike', 'caps',
'shadow', 'small_caps', 'spacing', 'vertical_align') 'shadow', 'small_caps', 'spacing', 'vertical_align') + tuple(
x%edge for edge in border_edges for x in border_props)
def __init__(self, css): def __init__(self, css):
self.font_family = css['font-family'] # TODO: Resolve multiple font families and generic font family names self.font_family = css_font_family_to_docx(css['font-family'])
try: try:
self.font_size = int(float(css['font-size']) * 2) # stylizer normalizes all font sizes into pts self.font_size = max(0, int(float(css['font-size']) * 2)) # stylizer normalizes all font sizes into pts
except (ValueError, TypeError, AttributeError): except (ValueError, TypeError, AttributeError):
self.font_size = None self.font_size = None
fw = self.font_weight = css['font-weight'] fw = css['font-weight']
self.bold = fw in {'bold', 'bolder'} or int_or_zero(fw) >= 700 self.bold = fw.lower() in {'bold', 'bolder'} or int_or_zero(fw) >= 700
self.font_style = css['font-style'] self.italic = css['font-style'].lower() in {'italic', 'oblique'}
self.italic = self.font_style in {'italic', 'oblique'}
self.color = convert_color(css['color']) self.color = convert_color(css['color'])
self.background_color = convert_color(css.backgroundColor) self.background_color = convert_color(css.backgroundColor)
td = set((css.effective_text_decoration or '').split()) td = set((css.effective_text_decoration or '').split())
@ -122,17 +143,64 @@ class TextStyle(DOCXStyle):
self.strike = not self.dstrike and 'line-through' in td self.strike = not self.dstrike and 'line-through' in td
self.text_transform = css['text-transform'] # TODO: If lowercase or capitalize, transform the actual text self.text_transform = css['text-transform'] # TODO: If lowercase or capitalize, transform the actual text
self.caps = self.text_transform == 'uppercase' self.caps = self.text_transform == 'uppercase'
self.small_caps = css['font-variant'].lower() in {'small-caps', 'smallcaps'}
self.shadow = css['text-shadow'] not in {'none', None} self.shadow = css['text-shadow'] not in {'none', None}
self.small_caps = css['font-variant'] in {'small-caps', 'smallcaps'}
try: try:
self.spacing = int(float(css['letter-spacing']) * 20) self.spacing = int(float(css['letter-spacing']) * 20)
except (ValueError, TypeError, AttributeError): except (ValueError, TypeError, AttributeError):
self.spacing = None self.spacing = None
self.vertical_align = {'sub':'subscript', 'super':'superscript'}.get((css['vertical-align'] or '').lower(), 'baseline') self.vertical_align = css['vertical-align']
# TODO: Borders and padding for edge in border_edges:
# In DOCX padding can only be a positive integer
setattr(self, 'padding_' + edge, max(0, int(css['padding-' + edge])))
val = min(96, max(2, int({'thin':0.2, 'medium':1, 'thick':2}.get(css['border-%s-width' % edge], 0) * 8)))
setattr(self, 'border_%s_width' % edge, val)
setattr(self, 'border_%s_color' % edge, convert_color(css['border-%s-color' % edge]))
setattr(self, 'border_%s_style' % edge, LINE_STYLES.get(css['border-%s-style' % edge].lower(), 'none'))
DOCXStyle.__init__(self) DOCXStyle.__init__(self)
def serialize(self, style):
style.append(style.makeelement(w('rFonts'), **{
w(k):self.font_family for k in 'ascii cs eastAsia hAnsi'.split()}))
for suffix in ('', 'Cs'):
style.append(style.makeelement(w('sz' + suffix), **{w('val'):str(self.font_size)}))
style.append(style.makeelement(w('b' + suffix), **{w('val'):('on' if self.bold else 'off')}))
style.append(style.makeelement(w('i' + suffix), **{w('val'):('on' if self.italic else 'off')}))
if self.color:
style.append(style.makeelement(w('color'), **{w('val'):str(self.color)}))
if self.background_color:
style.append(style.makeelement(w('shd'), **{w('val'):str(self.background_color)}))
if self.underline:
style.append(style.makeelement(w('u'), **{w('val'):'single'}))
if self.dstrike:
style.append(style.makeelement(w('dstrike'), **{w('val'):'on'}))
elif self.strike:
style.append(style.makeelement(w('strike'), **{w('val'):'on'}))
if self.caps:
style.append(style.makeelement(w('caps'), **{w('val'):'on'}))
if self.small_caps:
style.append(style.makeelement(w('smallCaps'), **{w('val'):'on'}))
if self.shadow:
style.append(style.makeelement(w('shadow'), **{w('val'):'on'}))
if self.spacing is not None:
style.append(style.makeelement(w('spacing'), **{w('val'):str(self.spacing)}))
if isinstance(self.vertical_align, (int, float)):
val = int(self.vertical_align * 2)
style.append(style.makeelement(w('position'), **{w('val'):str(val)}))
elif isinstance(self.vertical_align, basestring):
val = {'top':'superscript', 'text-top':'superscript', 'sup':'superscript', 'bottom':'subscript', 'text-bottom':'subscript', 'sub':'subscript'}.get(
self.vertical_align.lower())
if val:
style.append(style.makeelement(w('vertAlign'), **{w('val'):val}))
bdr = self.serialize_borders(style.makeelement(w('bdr')))
if len(bdr):
style.append(bdr)
return style
class BlockStyle(DOCXStyle): class BlockStyle(DOCXStyle):
ALL_PROPS = tuple( ALL_PROPS = tuple(
@ -155,13 +223,13 @@ class BlockStyle(DOCXStyle):
setattr(self, 'css_margin_' + edge, css._style.get('margin-' + edge, '')) setattr(self, 'css_margin_' + edge, css._style.get('margin-' + edge, ''))
val = min(96, max(2, int({'thin':0.2, 'medium':1, 'thick':2}.get(css['border-%s-width' % edge], 0) * 8))) val = min(96, max(2, int({'thin':0.2, 'medium':1, 'thick':2}.get(css['border-%s-width' % edge], 0) * 8)))
setattr(self, 'border_%s_width' % edge, val) setattr(self, 'border_%s_width' % edge, val)
setattr(self, 'border_%s_color' % edge, css_color_to_rgb(css['border-%s-color' % edge])) setattr(self, 'border_%s_color' % edge, convert_color(css['border-%s-color' % edge]))
setattr(self, 'border_%s_style' % edge, LINE_STYLES.get(css['border-%s-style' % edge].lower(), 'none')) setattr(self, 'border_%s_style' % edge, LINE_STYLES.get(css['border-%s-style' % edge].lower(), 'none'))
self.text_indent = max(0, int(css['text-indent'] * 20)) self.text_indent = max(0, int(css['text-indent'] * 20))
self.css_text_indent = css._get('text-indent') self.css_text_indent = css._get('text-indent')
self.line_height = max(0, int(css['line-height'] * 20)) self.line_height = max(0, int(css['line-height'] * 20))
self.css_line_height = css._get('line-height') self.css_line_height = css._get('line-height')
self.background_color = css_color_to_rgb(css['background-color']) self.background_color = convert_color(css['background-color'])
self.text_align = {'start':'left', 'left':'left', 'end':'right', 'right':'right', 'center':'center', 'justify':'both', 'centre':'center'}.get( self.text_align = {'start':'left', 'left':'left', 'end':'right', 'right':'right', 'center':'center', 'justify':'both', 'centre':'center'}.get(
css['text-align'].lower(), 'left') css['text-align'].lower(), 'left')
@ -223,20 +291,7 @@ class BlockStyle(DOCXStyle):
style.append(shd) style.append(shd)
shd.set(w('val'), 'clear'), shd.set(w('fill'), self.background_color), shd.set(w('color'), 'auto') shd.set(w('val'), 'clear'), shd.set(w('fill'), self.background_color), shd.set(w('color'), 'auto')
pbdr = style.makeelement(w('pBdr')) pbdr = self.serialize_borders(style.makeelement(w('pBdr')))
for edge in border_edges:
e = pbdr.makeelement(w(edge))
padding = getattr(self, 'padding_' + edge)
if padding > 0:
e.set(w('space'), str(padding))
width = getattr(self, 'border_%s_width' % edge)
bstyle = getattr(self, 'border_%s_style' % edge)
if width > 0 and bstyle != 'none':
e.set(w('val'), bstyle)
e.set(w('sz'), str(width))
e.set(w('color'), getattr(self, 'border_%s_color' % edge))
if e.attrib:
pbdr.append(e)
if len(pbdr): if len(pbdr):
style.append(pbdr) style.append(pbdr)
jc = style.makeelement(w('jc')) jc = style.makeelement(w('jc'))

View File

@ -6,8 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import re from tinycss.color3 import parse_color_string
from cssutils.css.colors import COLORS
def int_or_zero(raw): def int_or_zero(raw):
try: try:
@ -16,31 +15,17 @@ def int_or_zero(raw):
return 0 return 0
# convert_color() {{{ # convert_color() {{{
hex_pat = re.compile(r'#([0-9a-f]{6})') def convert_color(value):
hex3_pat = re.compile(r'#([0-9a-f]{3})') if not value:
rgb_pat = re.compile(r'rgba?\s*\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)') return
if value.lower() == 'currentcolor':
def convert_color(c): return 'auto'
if not c: val = parse_color_string(value)
return None if val is None:
c = c.lower().strip() return
if c == 'transparent': if val.alpha < 0.01:
return None return
try: return '%02X%02X%02X' % (int(val.red * 255), int(val.green * 255), int(val.blue * 255))
cval = COLORS[c]
except KeyError:
m = hex_pat.match(c)
if m is not None:
return c.upper()
m = hex3_pat.match(c)
if m is not None:
return '#' + (c[1]*2) + (c[2]*2) + (c[3]*2)
m = rgb_pat.match(c)
if m is not None:
return '#' + ''.join('%02X' % int(m.group(i)) for i in (1, 2, 3))
else:
return '#' + ''.join('%02X' % int(x) for x in cval[:3])
return None
def test_convert_color(): def test_convert_color():
import unittest import unittest
@ -53,12 +38,15 @@ def test_convert_color():
ae(None, cc('transparent')) ae(None, cc('transparent'))
ae(None, cc('none')) ae(None, cc('none'))
ae(None, cc('#12j456')) ae(None, cc('#12j456'))
ae('#F0F8FF', cc('AliceBlue')) ae('auto', cc('currentColor'))
ae('#000000', cc('black')) ae('F0F8FF', cc('AliceBlue'))
ae(cc('#001'), '#000011') ae('000000', cc('black'))
ae('#12345D', cc('#12345d')) ae('FF0000', cc('red'))
ae('#FFFFFF', cc('rgb(255, 255, 255)')) ae('00FF00', cc('lime'))
ae('#FF0000', cc('rgba(255, 0, 0, 23)')) ae(cc('#001'), '000011')
ae('12345D', cc('#12345d'))
ae('FFFFFF', cc('rgb(255, 255, 255)'))
ae('FF0000', cc('rgba(255, 0, 0, 23)'))
tests = unittest.defaultTestLoader.loadTestsFromTestCase(TestColors) tests = unittest.defaultTestLoader.loadTestsFromTestCase(TestColors)
unittest.TextTestRunner(verbosity=4).run(tests) unittest.TextTestRunner(verbosity=4).run(tests)
# }}} # }}}