~ubuntu-branches/ubuntu/karmic/calibre/karmic

« back to all changes in this revision

Viewing changes to src/calibre/ebooks/epub/fonts.py

  • Committer: Bazaar Package Importer
  • Author(s): Martin Pitt
  • Date: 2009-07-30 12:49:41 UTC
  • mfrom: (1.3.2 upstream)
  • Revision ID: james.westby@ubuntu.com-20090730124941-qjdsmri25zt8zocn
Tags: 0.6.3+dfsg-0ubuntu1
* New upstream release. Please see http://calibre.kovidgoyal.net/new_in_6/
  for the list of new features and changes.
* remove_postinstall.patch: Update for new version.
* build_debug.patch: Does not apply any more, disable for now. Might not be
  necessary any more.
* debian/copyright: Fix reference to versionless GPL.
* debian/rules: Drop obsolete dh_desktop call.
* debian/rules: Add workaround for weird Python 2.6 setuptools behaviour of
  putting compiled .so files into src/calibre/plugins/calibre/plugins
  instead of src/calibre/plugins.
* debian/rules: Drop hal fdi moving, new upstream version does not use hal
  any more. Drop hal dependency, too.
* debian/rules: Install udev rules into /lib/udev/rules.d.
* Add debian/calibre.preinst: Remove unmodified
  /etc/udev/rules.d/95-calibre.rules on upgrade.
* debian/control: Bump Python dependencies to 2.6, since upstream needs
  it now.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#!/usr/bin/env  python
2
 
__license__   = 'GPL v3'
3
 
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
4
 
__docformat__ = 'restructuredtext en'
5
 
 
6
 
'''
7
 
Font size rationalization. See :function:`relativize`.
8
 
'''
9
 
 
10
 
import logging, re, operator, functools, collections, unittest, copy, sys
11
 
from xml.dom import SyntaxErr
12
 
 
13
 
from lxml.cssselect import CSSSelector
14
 
from lxml import etree
15
 
from lxml.html import HtmlElement
16
 
 
17
 
from calibre.ebooks.html import fromstring
18
 
from calibre.ebooks.epub import rules
19
 
from cssutils import CSSParser
20
 
 
21
 
num           = r'[-]?\d+|[-]?\d*\.\d+'
22
 
length        = r'(?P<zero>0)|(?P<num>{num})(?P<unit>%|em|ex|px|in|cm|mm|pt|pc)'.replace('{num}', num)
23
 
absolute_size = r'(?P<abs>(x?x-)?(small|large)|medium)'
24
 
relative_size = r'(?P<rel>smaller|larger)'
25
 
 
26
 
font_size_pat   = re.compile('|'.join((relative_size, absolute_size, length)), re.I)
27
 
line_height_pat = re.compile(r'({num})(px|in|cm|mm|pt|pc)'.replace('{num}', num))  
28
 
 
29
 
PTU = {
30
 
       'in' : 72.,
31
 
       'cm' : 72/2.54,
32
 
       'mm' : 72/25.4,
33
 
       'pt' : 1.0,
34
 
       'pc' : 1/12.,
35
 
       }
36
 
 
37
 
DEFAULT_FONT_SIZE = 12
38
 
 
39
 
class Rationalizer(object):
40
 
    
41
 
    @classmethod
42
 
    def specificity(cls, s):
43
 
        '''Map CSS specificity tuple to a single integer'''
44
 
        return sum([10**(4-i) + x for i,x in enumerate(s)]) 
45
 
        
46
 
    @classmethod
47
 
    def compute_font_size(cls, elem):
48
 
        '''
49
 
        Calculate the effective font size of an element traversing its ancestors as far as
50
 
        neccessary.
51
 
        '''
52
 
        cfs = elem.computed_font_size
53
 
        if cfs is not None:
54
 
            return
55
 
        sfs = elem.specified_font_size
56
 
        if callable(sfs):
57
 
            parent = elem.getparent()
58
 
            cls.compute_font_size(parent)
59
 
            elem.computed_font_size = sfs(parent.computed_font_size)
60
 
        else:
61
 
            elem.computed_font_size = sfs
62
 
        
63
 
    @classmethod
64
 
    def calculate_font_size(cls, style):
65
 
        'Return font size in pts from style object. For relative units returns a callable'
66
 
        match = font_size_pat.search(style.font)
67
 
        fs = ''
68
 
        if match:
69
 
            fs = match.group()
70
 
        if style.fontSize:
71
 
            fs = style.fontSize
72
 
            
73
 
        match = font_size_pat.search(fs)
74
 
        if match is None:
75
 
            return None
76
 
        match = match.groupdict()
77
 
        unit = match.get('unit', '')
78
 
        if unit: unit = unit.lower()
79
 
        if unit in PTU.keys():
80
 
            return PTU[unit] * float(match['num'])
81
 
        if unit in ('em', 'ex'):
82
 
            return functools.partial(operator.mul, float(match['num']))
83
 
        if unit == '%':
84
 
            return functools.partial(operator.mul, float(match['num'])/100.)
85
 
        abs = match.get('abs', '')
86
 
        if abs: abs = abs.lower()
87
 
        if abs:
88
 
            x = (1.2)**(abs.count('x') * (-1 if 'small' in abs else 1))
89
 
            return 12 * x
90
 
        if match.get('zero', False):
91
 
            return 0.
92
 
        return functools.partial(operator.mul, 1.2) if 'larger' in fs.lower() else functools.partial(operator.mul, 0.8) 
93
 
        
94
 
    @classmethod
95
 
    def resolve_rules(cls, stylesheets):
96
 
        for sheet in stylesheets:
97
 
            if hasattr(sheet, 'fs_rules'):
98
 
                continue
99
 
            sheet.fs_rules = []
100
 
            sheet.lh_rules = []
101
 
            for r in sheet:
102
 
                if r.type == r.STYLE_RULE:
103
 
                    font_size = cls.calculate_font_size(r.style)
104
 
                    if font_size is not None:
105
 
                        for s in r.selectorList:
106
 
                            sheet.fs_rules.append([CSSSelector(s.selectorText), font_size])
107
 
                    orig = line_height_pat.search(r.style.lineHeight) 
108
 
                    if orig is not None:
109
 
                        for s in r.selectorList:
110
 
                            sheet.lh_rules.append([CSSSelector(s.selectorText), float(orig.group(1)) * PTU[orig.group(2).lower()]])
111
 
    
112
 
        
113
 
    @classmethod
114
 
    def apply_font_size_rules(cls, stylesheets, root):
115
 
        'Add a ``specified_font_size`` attribute to every element that has a specified font size'
116
 
        cls.resolve_rules(stylesheets)
117
 
        for sheet in stylesheets:
118
 
            for selector, font_size in sheet.fs_rules:
119
 
                elems = selector(root)
120
 
                for elem in elems:
121
 
                    elem.specified_font_size = font_size
122
 
    
123
 
    @classmethod
124
 
    def remove_font_size_information(cls, stylesheets):
125
 
        for r in rules(stylesheets):
126
 
            r.style.removeProperty('font-size')
127
 
            try:
128
 
                new = font_size_pat.sub('', r.style.font).strip()
129
 
                if new:
130
 
                    r.style.font = new
131
 
                else:
132
 
                    r.style.removeProperty('font')
133
 
            except SyntaxErr:
134
 
                r.style.removeProperty('font')
135
 
            if line_height_pat.search(r.style.lineHeight) is not None:
136
 
                r.style.removeProperty('line-height')
137
 
    
138
 
    @classmethod
139
 
    def compute_font_sizes(cls, root, stylesheets, base=12):
140
 
        stylesheets = [s for s in stylesheets if hasattr(s, 'cssText')]
141
 
        cls.apply_font_size_rules(stylesheets, root)
142
 
        
143
 
        # Compute the effective font size of all tags
144
 
        root.computed_font_size = DEFAULT_FONT_SIZE
145
 
        for elem in root.iter(etree.Element):
146
 
            cls.compute_font_size(elem)
147
 
        
148
 
        extra_css = {}
149
 
        if base > 0:
150
 
            # Calculate the "base" (i.e. most common) font size
151
 
            font_sizes = collections.defaultdict(lambda : 0)
152
 
            body = root.xpath('//body')[0]
153
 
            IGNORE = ('h1', 'h2', 'h3', 'h4', 'h5', 'h6')
154
 
            for elem in body.iter(etree.Element):
155
 
                if elem.tag not in IGNORE:
156
 
                    t = getattr(elem, 'text', '')
157
 
                    if t: t = t.strip()
158
 
                    if t:
159
 
                        font_sizes[elem.computed_font_size] += len(t)
160
 
                    
161
 
                t = getattr(elem, 'tail', '')
162
 
                if t: t = t.strip()
163
 
                if t:
164
 
                    parent = elem.getparent()
165
 
                    if parent.tag not in IGNORE:
166
 
                        font_sizes[parent.computed_font_size] += len(t)
167
 
                
168
 
            try:
169
 
                most_common = max(font_sizes.items(), key=operator.itemgetter(1))[0]
170
 
                scale = base/most_common if most_common > 0 else 1.
171
 
            except ValueError:
172
 
                scale = 1.
173
 
            
174
 
            # rescale absolute line-heights
175
 
            counter = 0
176
 
            for sheet in stylesheets:
177
 
                for selector, lh in sheet.lh_rules:
178
 
                    for elem in selector(root):
179
 
                        elem.set('id', elem.get('id', 'cfs_%d'%counter))
180
 
                        counter += 1
181
 
                        if not extra_css.has_key(elem.get('id')):
182
 
                            extra_css[elem.get('id')] = []
183
 
                        extra_css[elem.get('id')].append('line-height:%fpt'%(lh*scale))
184
 
            
185
 
        
186
 
            
187
 
            # Rescale all computed font sizes
188
 
            for elem in body.iter(etree.Element):
189
 
                if isinstance(elem, HtmlElement):
190
 
                    elem.computed_font_size *= scale
191
 
        
192
 
        # Remove all font size specifications from the last stylesheet 
193
 
        cls.remove_font_size_information(stylesheets[-1:])
194
 
                    
195
 
        # Create the CSS to implement the rescaled font sizes
196
 
        for elem in body.iter(etree.Element):
197
 
            cfs, pcfs = map(operator.attrgetter('computed_font_size'), (elem, elem.getparent()))
198
 
            if abs(cfs-pcfs) > 1/12. and abs(pcfs) > 1/12.:
199
 
                elem.set('id', elem.get('id', 'cfs_%d'%counter))
200
 
                counter += 1
201
 
                if not extra_css.has_key(elem.get('id')):
202
 
                    extra_css[elem.get('id')] = []
203
 
                extra_css[elem.get('id')].append('font-size: %f%%'%(100*(cfs/pcfs)))
204
 
                
205
 
        css = CSSParser(loglevel=logging.ERROR).parseString('')
206
 
        for id, r in extra_css.items():
207
 
            css.add('#%s {%s}'%(id, ';'.join(r)))
208
 
        return css
209
 
    
210
 
    @classmethod
211
 
    def rationalize(cls, stylesheets, root, opts):
212
 
        logger     = logging.getLogger('html2epub')
213
 
        logger.info('\t\tRationalizing fonts...')
214
 
        extra_css = None
215
 
        if opts.base_font_size2 > 0:
216
 
            try:
217
 
                extra_css = cls.compute_font_sizes(root, stylesheets, base=opts.base_font_size2)
218
 
            except:
219
 
                logger.warning('Failed to rationalize font sizes.')
220
 
                if opts.verbose > 1:
221
 
                    logger.exception('')
222
 
            finally:
223
 
                root.remove_font_size_information()
224
 
        logger.debug('\t\tDone rationalizing')
225
 
        return extra_css
226
 
 
227
 
################################################################################
228
 
############## Testing
229
 
################################################################################
230
 
 
231
 
class FontTest(unittest.TestCase):
232
 
    
233
 
    def setUp(self):
234
 
        from calibre.ebooks.epub import config
235
 
        self.opts = config(defaults='').parse()
236
 
        self.html = '''
237
 
        <html>
238
 
            <head>
239
 
                <title>Test document</title>
240
 
            </head>
241
 
            <body>
242
 
                <div id="div1">
243
 
                <!-- A comment -->
244
 
                    <p id="p1">Some <b>text</b></p>
245
 
                </div>
246
 
                <p id="p2">Some other <span class="it">text</span>.</p>
247
 
                <p id="longest">The longest piece of single font size text in this entire file. Used to test resizing.</p>
248
 
            </body>
249
 
        </html> 
250
 
        '''
251
 
        self.root = fromstring(self.html)
252
 
        
253
 
    def do_test(self, css, base=DEFAULT_FONT_SIZE, scale=1):
254
 
        root1 = copy.deepcopy(self.root)
255
 
        root1.computed_font_size = DEFAULT_FONT_SIZE
256
 
        stylesheet = CSSParser(loglevel=logging.ERROR).parseString(css)
257
 
        stylesheet2 = Rationalizer.compute_font_sizes(root1, [stylesheet], base)
258
 
        root2 = copy.deepcopy(root1)
259
 
        root2.remove_font_size_information()
260
 
        root2.computed_font_size = DEFAULT_FONT_SIZE
261
 
        Rationalizer.apply_font_size_rules([stylesheet2], root2)
262
 
        for elem in root2.iter(etree.Element):
263
 
            Rationalizer.compute_font_size(elem)
264
 
        for e1, e2 in zip(root1.xpath('//body')[0].iter(etree.Element), root2.xpath('//body')[0].iter(etree.Element)):
265
 
            self.assertAlmostEqual(e1.computed_font_size, e2.computed_font_size, 
266
 
                msg='Computed font sizes for %s not equal. Original: %f Processed: %f'%\
267
 
                (root1.getroottree().getpath(e1), e1.computed_font_size, e2.computed_font_size))
268
 
        return stylesheet2.cssText
269
 
        
270
 
    def testStripping(self):
271
 
        'Test that any original entries are removed from the CSS'
272
 
        css = 'p { font: bold 10px italic smaller; font-size: x-large} \na { font-size: 0 }'
273
 
        css = CSSParser(loglevel=logging.ERROR).parseString(css)
274
 
        Rationalizer.compute_font_sizes(copy.deepcopy(self.root), [css])
275
 
        self.assertEqual(css.cssText.replace(' ', '').replace('\n', ''), 
276
 
                         'p{font:bolditalic}')
277
 
    
278
 
    def testIdentity(self):
279
 
        'Test that no unnecessary font size changes are made'
280
 
        extra_css = self.do_test('div {font-size:12pt} \nspan {font-size:100%}')
281
 
        self.assertEqual(extra_css.strip(), '')
282
 
        
283
 
    def testRelativization(self):
284
 
        'Test conversion of absolute to relative sizes'
285
 
        self.do_test('#p1 {font: 24pt} b {font: 12pt} .it {font: 48pt} #p2 {font: 100%}')
286
 
        
287
 
    def testResizing(self):
288
 
        'Test resizing of fonts'
289
 
        self.do_test('#longest {font: 24pt} .it {font:20pt; line-height:22pt}')
290
 
        
291
 
 
292
 
def suite():
293
 
    return unittest.TestLoader().loadTestsFromTestCase(FontTest)
294
 
    
295
 
def test():
296
 
    unittest.TextTestRunner(verbosity=2).run(suite())
297
 
 
298
 
if __name__ == '__main__':
299
 
    sys.exit(test())    
300
 
        
 
 
b'\\ No newline at end of file'