Web · Wiki · Activities · Blog · Lists · Chat · Meeting · Bugs · Git · Translate · Archive · People · Donate
1
#!/usr/bin/env python
2
#
3
# Author: Sascha Silbe <sascha-pgp@silbe.org>
4
#
5
# This program is free software; you can redistribute it and/or modify
6
# it under the terms of the GNU General Public License version 3
7
# as published by the Free Software Foundation.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License
15
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
"""Scan: Import images using a document scanner.
17
"""
18
from __future__ import with_statement
19
20
from gettext import lgettext as _
21
from gettext import lngettext as ngettext
22
import logging
23
import math
24
import os
25
import sys
26
import tempfile
27
import time
28
29
import gobject
30
import gtk
31
32
import reportlab.pdfgen.canvas
33
from reportlab.lib import units
34
import sane
35
import threading
36
37
from sugar.activity.widgets import ActivityToolbarButton, StopButton
38
from sugar.graphics.toggletoolbutton import ToggleToolButton
39
from sugar.activity import activity
40
from sugar.datastore import datastore
41
from sugar.graphics.toolbutton import ToolButton
42
from sugar.graphics.combobox import ComboBox
43
from sugar.graphics.toolbarbox import ToolbarButton, ToolbarBox
44
from sugar.graphics import style
45
from sugar.logger import trace
46
47
48
class SettingsToolbar(gtk.Toolbar):
49
50
    @trace()
51
    def __init__(self):
52
        gtk.Toolbar.__init__(self)
53
        self._scanner = None
54
        self.props.show_arrow = True
55
56
    def set_scanner(self, scanner):
57
        self._scanner = scanner
58
        self._clear()
59
        if self._scanner:
60
            self._add_options()
61
62
    def _clear(self):
63
        for widget in list(self):
64
            self.remove(widget)
65
66
    @trace()
67
    def _get_groups(self):
68
        group = []
69
        groups = [(None, group)]
70
        options = self._scanner.get_options()
71
        for idx, name, title, dsc_, _type, unit_, siz_, cap_, cons_ in options:
72
            if _type == sane.TYPE_GROUP:
73
                group = []
74
                groups.append((title, group))
75
                continue
76
77
            if not name and idx != 0:
78
                logging.warning('ignoring unnamed option (index %d)', idx)
79
                continue
80
81
            group.append(name)
82
83
        return groups
84
85
    def _add_options(self):
86
        groups = self._get_groups()
87
        name_map = dict([(self._scanner[py_name].name, py_name)
88
            for py_name in self._scanner.optlist])
89
90
        first_group = True
91
        for group_name, group in groups:
92
            if not group:
93
                continue
94
95
            if first_group:
96
                first_group = False
97
            else:
98
                self.insert(gtk.SeparatorToolItem(), -1)
99
100
            # TODO: handle SANE_CAP_ADVANCED and maybe other capabilities
101
            # TODO: handle area by paper size presets + single fields in
102
            # advanced options
103
104
            for option_name in group:
105
                py_name = name_map[option_name]
106
                option = self._scanner[py_name]
107
                if not option.is_active():
108
                    # FIXME: check SANE docs what inactive is used for
109
                    # I have a feeling we might need to cope with this
110
                    # changing after changing other settings like the mode
111
                    # (color/gray/...)
112
                    logging.warning('skipping inactive option %r', option_name)
113
                    continue
114
115
                value = getattr(self._scanner, py_name)
116
                if (option.type == sane.TYPE_BOOL):
117
                    widget = self._add_bool(option, value)
118
                elif (option.type == sane.TYPE_INT):
119
                    widget = self._add_integer(option, value)
120
                elif (option.type == sane.TYPE_FIXED):
121
                    widget = self._add_fixed_point(option, value)
122
                elif (option.type == sane.TYPE_STRING):
123
                    widget = self._add_string(option, value)
124
                elif (option.type == sane.TYPE_BUTTON):
125
                    widget = self._add_action(option, value)
126
127
                if not widget:
128
                    continue
129
130
                widget.set_sensitive(option.is_settable())
131
132
                # FIXME: take translation from SANE
133
                label = gtk.Label(_(option.title))
134
                hbox = gtk.HBox(False, style.DEFAULT_SPACING)
135
                hbox.pack_start(label, False)
136
                hbox.pack_start(widget)
137
                label.show()
138
139
                tool_item = gtk.ToolItem()
140
                tool_item.add(hbox)
141
                # FIXME: take translation from SANE
142
                tool_item.set_tooltip_text(_(option.desc))
143
                # TODO: represent unit
144
                self.insert(tool_item, -1)
145
146
        self.show_all()
147
148
    def _add_bool(self, option, value):
149
        button = gtk.CheckButton()
150
        button.set_active(value)
151
        button.connect('toggled', lambda widget,
152
            option=option: self._toggle_cb(widget, option))
153
        return button
154
155
    def _add_integer(self, option, value):
156
        if isinstance(option.constraint, tuple):
157
            return self._add_spinbutton(option, value)
158
        elif isinstance(option.constraint, list):
159
            # TODO
160
            return None
161
        return self._add_spinbutton(option, value)
162
163
    def _add_fixed_point(self, option, value):
164
        if isinstance(option.constraint, tuple):
165
            return self._add_spinbutton(option, value)
166
        elif isinstance(option.constraint, list):
167
            # TODO
168
            return None
169
        else:
170
            # TODO
171
            return None
172
173
    @trace()
174
    def _add_string(self, option, value):
175
        if option.constraint:
176
            return self._add_combo(option, value)
177
178
        return self._add_entry(option, value)
179
180
    def _add_action(self, option, value):
181
        # TODO
182
        return None
183
184
    def _add_spinbutton(self, option, value):
185
        if isinstance(option.constraint, tuple):
186
            lower, upper, step = option.constraint
187
        else:
188
            lower, upper, step = 0, sys.maxint, 1
189
190
        if not step:
191
            # no quantization set => guess it
192
            step = 1
193
194
        spin_adj = gtk.Adjustment(value, lower, upper, step, step*10, 0)
195
        spin = gtk.SpinButton(spin_adj, 0, 0)
196
        spin.props.snap_to_ticks = True
197
        spin.set_digits(self._calc_digits(step))
198
199
        if isinstance(value, float):
200
            spin.connect('value-changed', lambda widget, option=option:
201
                self._spin_float_changed_cb(widget, option))
202
        else:
203
            spin.connect('value-changed', lambda widget, option=option:
204
                self._spin_int_changed_cb(widget, option))
205
206
        spin.set_numeric(True)
207
        spin.show()
208
209
        return spin
210
211
    def _calc_digits(self, step):
212
        digits = 0
213
        while math.modf(step)[0] > .001:
214
            digits += 1
215
            step *= 10
216
217
        return digits
218
219
    def _add_entry(self, option, value):
220
        # untested
221
        entry = gtk.Entry()
222
        entry.set_text(value)
223
        entry.connect('focus-out-event', lambda widget, option=option:
224
            self._entry_changed_cb(widget, option))
225
        entry.connect('activated', lambda widget, option=option:
226
            self._entry_changed_cb(widget, option))
227
        return entry
228
229
    @trace()
230
    def _add_combo(self, option, value):
231
        box = ComboBox()
232
        for combo_value in option.constraint:
233
            # FIXME: take translation from SANE
234
            box.append_item(combo_value, _(combo_value))
235
236
        new_idx = option.constraint.index(value)
237
        box.set_active(new_idx)
238
        box.connect('changed', lambda widget, option=option:
239
            self._combo_changed_cb(widget, option))
240
        return box
241
242
    @trace()
243
    def _toggle_cb(self, button, option):
244
        new_value = button.get_active()
245
        setattr(self._scanner, option.py_name, new_value)
246
247
    @trace()
248
    def _entry_changed_cb(self, entry, option):
249
        setattr(self._scanner, option.py_name, entry.get_text())
250
251
    @trace()
252
    def _combo_changed_cb(self, combo, option):
253
        setattr(self._scanner, option.py_name, combo.get_value())
254
255
    @trace()
256
    def _spin_float_changed_cb(self, spin, option):
257
        setattr(self._scanner, option.py_name, spin.get_value())
258
259
    @trace()
260
    def _spin_int_changed_cb(self, spin, option):
261
        setattr(self._scanner, option.py_name, int(spin.get_value()))
262
263
264
class ScanThread(threading.Thread):
265
266
    def __init__(self, devs, temp_dir, callback=None):
267
        threading.Thread.__init__(self)
268
        self._devs = devs
269
        self._temp_dir = temp_dir
270
        self._callback = callback
271
        self._cond = threading.Condition()
272
        self._action = None
273
        self._images = []
274
        self._error = None
275
        self._ready = False
276
        self._dpi = None
277
278
    def run(self):
279
        while True:
280
            with self._cond:
281
                while not self._action:
282
                    logging.debug('waiting')
283
                    self._cond.wait()
284
                    logging.debug('woken')
285
286
                action = self._action
287
                self._action = None
288
289
            if action == 'quit':
290
                return
291
292
            if action == 'start':
293
                self._scan()
294
295
    def stop_thread(self):
296
        with self._cond:
297
            self._action = 'quit'
298
            self._cond.notify()
299
300
    @trace()
301
    def start_scan(self):
302
        with self._cond:
303
            logging.debug('start_scan(): got lock')
304
            self._action = 'start'
305
            self._cond.notify()
306
            logging.debug('start_scan(): notified')
307
308
        logging.debug('start_scan(): lock released')
309
310
    @trace()
311
    def stop_scan(self):
312
        with self._cond:
313
            logging.debug('stop_scan(): got lock')
314
            self._action = 'stop'
315
            self._cond.notify()
316
            logging.debug('stop_scan(): notified')
317
318
        logging.debug('stop_scan(): lock released')
319
320
    def is_ready(self):
321
        with self._cond:
322
            return self._ready
323
324
    def get_result(self):
325
        with self._cond:
326
            if self._error:
327
                raise self._error
328
329
            images = self._images
330
            self._images = []
331
            return images
332
333
    @trace()
334
    def _scan(self):
335
        self._images = None
336
        self._error = None
337
338
        source = getattr(self._devs[0], 'source', 'Auto')
339
        if getattr(self._devs[0], 'batch_scan', False):
340
            scan_multi = source in ['Auto', 'ADF']
341
        else:
342
            scan_multi = source == 'ADF'
343
344
        try:
345
            if scan_multi:
346
                self._scan_multi()
347
            else:
348
                logging.debug('starting single scan')
349
                self._dpi = self._devs[0].resolution
350
                self._images = [self._process_image(self._devs[0].scan())]
351
352
        except sane.error, exc:
353
            # Ouch. sane.error doesn't derive from Exception :-/
354
            self._error = ValueError('SANE error: %s' % (exc, ))
355
356
        except Exception, exc:
357
            self._error = exc
358
359
        with self._cond:
360
            self._ready = True
361
362
        try:
363
            self._callback(self._images, self._error)
364
365
        except:
366
            logging.exception('_scan(): Uncaught exception in callback')
367
368
    @trace()
369
    def _scan_multi(self):
370
        logging.debug('_scan: calling multi_scan()')
371
        scan_iter = self._devs[0].multi_scan()
372
373
        logging.debug('_scan: acquiring images')
374
        images = []
375
        try:
376
            self._dpi = self._devs[0].resolution
377
            for image in scan_iter:
378
                images.append(self._process_image(image))
379
                if self._check_action(['stop', 'quit']):
380
                    break
381
382
                self._dpi = self._devs[0].resolution
383
384
            self._images = images
385
386
        finally:
387
            logging.debug('_scan(): closing iterator')
388
            # make sure the scan is "cancelled" _now_
389
            del scan_iter
390
            logging.debug('_scan(): iterator closed')
391
392
    @trace()
393
    def _check_action(self, actions):
394
        with self._cond:
395
            return self._action in actions
396
397
    def _process_image(self, image):
398
        image_file = tempfile.NamedTemporaryFile(dir=self._temp_dir,
399
            suffix='.png')
400
        image.save(image_file.name)
401
        image_file.flush()
402
        info = {
403
            'file': image_file,
404
            'width_pixel': image.size[0],
405
            'height_pixel': image.size[1],
406
            'dpi': self._dpi,
407
        }
408
        del image
409
        return info
410
411
412
class ScanActivity(activity.Activity):
413
414
    _STATUS_MSGS = {
415
        'no-dev': _('No scanner found'),
416
        'ready': _('Ready'),
417
        'stopping': _('Stopping...'),
418
        'scanning': _('Scanning...'),
419
        'error': _('Error: %s'),
420
    }
421
422
    def __init__(self, handle):
423
        activity.Activity.__init__(self, handle)
424
        self.max_participants = 1
425
        self._scanner_name = None
426
        self._scanners = []
427
        self._scanner_options = {'source': 'ADF'}
428
        self._image_infos = []
429
        self._status = 'init'
430
        self._msg_box_buf = None
431
        self._status_entry = None
432
        self._scan_button = None
433
        self._settings_toolbar = None
434
        temp_dir = os.path.join(self.get_activity_root(), 'tmp')
435
        self._scan_thread = ScanThread(self._scanners, temp_dir,
436
            callback=self._scan_finished_cb)
437
        self._scan_thread.start()
438
        self._setup_widgets()
439
        self._init_scanner()
440
441
    @trace()
442
    def destroy(self):
443
        self._scan_thread.stop_thread()
444
        self._scan_thread.join()
445
        logging.debug('ScanThread finished')
446
        [info['file'].close() for info in self._image_infos]
447
        activity.Activity.destroy(self)
448
449
    def read_file(self, file_path):
450
        if not self._scanners:
451
            return
452
453
        globals_dict = {'__builtins__': None}
454
        settings = eval(file(file_path).read(), globals_dict)
455
        self._set_settings(settings)
456
        # TODO: don't call set_scanner() twice when resuming (once in
457
        # _init_scanner() and once here)
458
        self._settings_toolbar.set_scanner(self._scanners[0])
459
460
    def write_file(self, file_path):
461
        self.metadata['mime_type'] = 'text/plain'
462
        with file(file_path, 'w') as f:
463
            f.write(repr(self._get_settings()))
464
465
    @trace()
466
    def _get_settings(self):
467
        if not self._scanners:
468
            return {}
469
470
        settings = {}
471
        scanner = self._scanners[0]
472
        for py_name in scanner.optlist:
473
            logging.debug('getting value for setting %r', py_name)
474
            option = scanner[py_name]
475
            if option.type in [sane.TYPE_BOOL, sane.TYPE_INT,
476
                sane.TYPE_FIXED, sane.TYPE_STRING] and option.is_active():
477
478
                settings[py_name] = getattr(scanner, py_name)
479
480
        return settings
481
482
    @trace()
483
    def _set_settings(self, settings):
484
        if not self._scanners:
485
            return
486
487
        scanner = self._scanners[0]
488
        for name, value in settings.items():
489
            try:
490
                setattr(scanner, name, value)
491
492
            except AttributeError:
493
                logging.info('Saved setting %r=%r not support by current'
494
                    ' scanner', name, value)
495
496
            except sane.error:
497
                logging.exception('Cannot set saved setting %r=%r', name,
498
                    value)
499
500
    @trace()
501
    def _init_scanner(self):
502
        sane.init()
503
        scanner_devs = sane.get_devices()
504
        if not scanner_devs:
505
            self._set_status('no-dev')
506
            return
507
508
        self._scanner_name = scanner_devs[0][0]
509
        self._reopen_scanner()
510
511
    def _reopen_scanner(self):
512
        """Reopen scanner device to work around lower layer bugs."""
513
        if self._scanners:
514
            try:
515
                old_scanner = self._scanners.pop()
516
                old_scanner.close()
517
                del old_scanner
518
519
            except sane.error, exc:
520
                logging.exception('Error closing scanner')
521
522
        if not self._scanner_name:
523
            self._set_status('no-dev')
524
            return
525
526
        try:
527
            self._scanners.append(sane.open(self._scanner_name))
528
529
        except sane.error, exc:
530
            logging.exception('Error opening scanner %r', self._scanner_name)
531
            self._show_error(exc)
532
            return
533
534
        self._settings_toolbar.set_scanner(self._scanners[0])
535
536
        self._set_status('ready')
537
538
    def _setup_widgets(self):
539
        self._setup_toolbar()
540
        self._setup_canvas()
541
542
    def _setup_canvas(self):
543
        vbox = gtk.VBox()
544
        self._status_entry = gtk.Entry()
545
        self._status_entry.set_editable(False)
546
        self._status_entry.set_sensitive(False)
547
        vbox.pack_start(self._status_entry, expand=False)
548
        self._status_entry.show()
549
550
        msg_box = gtk.TextView()
551
        msg_box.set_editable(False)
552
        msg_box.set_wrap_mode(gtk.WRAP_WORD)
553
        self._msg_box_buf = msg_box.get_buffer()
554
        vbox.pack_start(msg_box, expand=True, fill=True)
555
        msg_box.show()
556
        self.set_canvas(vbox)
557
        vbox.show()
558
559
    def _setup_toolbar(self):
560
        toolbar_box = ToolbarBox()
561
562
        activity_button = ActivityToolbarButton(self)
563
        toolbar_box.toolbar.insert(activity_button, -1)
564
        activity_button.show()
565
566
        self._scan_button = ToolButton('start-scan1')
567
        self._scan_button.props.tooltip = _('Start scan')
568
        self._scan_button.connect('clicked', self._scan_button_cb)
569
        toolbar_box.toolbar.insert(self._scan_button, -1)
570
        self._scan_button.show()
571
572
        self._settings_toolbar = SettingsToolbar()
573
        settings_button = ToolbarButton()
574
        settings_button.props.page = self._settings_toolbar
575
        settings_button.props.icon_name = 'preferences-system'
576
        settings_button.props.label = _('Scanner settings')
577
        toolbar_box.toolbar.insert(settings_button, -1)
578
        settings_button.show()
579
580
        self._save_button = ToolButton('document-save')
581
        self._save_button.props.tooltip = _('Save collection as PDF')
582
        self._save_button.set_sensitive(False)
583
        self._save_button.connect('clicked', self._save_button_cb)
584
        toolbar_box.toolbar.insert(self._save_button, -1)
585
        self._save_button.show()
586
587
        separator = gtk.SeparatorToolItem()
588
        separator.props.draw = False
589
        separator.set_expand(True)
590
        toolbar_box.toolbar.insert(separator, -1)
591
        separator.show()
592
593
        stop_button = StopButton(self)
594
        toolbar_box.toolbar.insert(stop_button, -1)
595
        stop_button.show()
596
597
        self.set_toolbar_box(toolbar_box)
598
        toolbar_box.show()
599
600
    @trace()
601
    def _scan_button_cb(self, *args):
602
        if self._status == 'ready':
603
            self._add_msg('triggering scan')
604
            self._set_status('scanning')
605
            self._scan_thread.start_scan()
606
        elif self._status == 'scanning':
607
            self._add_msg('stopping scan')
608
            self._set_status('stopping')
609
            self._scan_thread.stop_scan()
610
        else:
611
            logging.warning('_scan_button_cb called with status=%r',
612
                self._status)
613
614
615
    @trace()
616
    def _scan_finished_cb(self, image_infos, exc):
617
        gobject.idle_add(self._scan_finished_real_cb, image_infos, exc)
618
619
    @trace()
620
    def _scan_finished_real_cb(self, image_infos, exc):
621
        if exc:
622
            self._show_error(exc)
623
624
        if image_infos:
625
            num_images = len(image_infos)
626
            self._add_msg(ngettext('%d page scanned', '%d pages scanned',
627
                num_images) % (num_images, ))
628
            self._save_button.set_sensitive(True)
629
630
            self._image_infos += image_infos
631
632
        if not exc:
633
            self._set_status('ready')
634
        else:
635
            self._reopen_scanner()
636
637
    @trace()
638
    def _save_button_cb(self, *args):
639
        # TODO: do processing in background (another thread?)
640
        save_dir = os.path.join(self.get_activity_root(), 'instance')
641
        fd, name = tempfile.mkstemp(dir=save_dir)
642
        with os.fdopen(fd, 'w') as f:
643
            self._save_pdf(f)
644
645
        jobject = datastore.create()
646
        jobject.metadata['mime_type'] = 'application/pdf'
647
        jobject.metadata['title'] = 'Scanned document'
648
        # TODO: add tags, description etc.
649
        jobject.file_path = name
650
        self._save_button.set_sensitive(False)
651
        datastore.write(jobject, transfer_ownership=True,
652
            reply_handler=self._save_reply_cb,
653
            error_handler=self._save_error_cb)
654
        jobject.destroy()
655
656
    @trace()
657
    def _save_pdf(self, f):
658
        max_width_inch = reduce(max,
659
            [info['width_pixel']/info['dpi'] for info in self._image_infos])
660
        max_height_inch = reduce(max,
661
            [info['height_pixel']/info['dpi'] for info in self._image_infos])
662
        canvas = reportlab.pdfgen.canvas.Canvas(f,
663
            pagesize=(max_width_inch*units.inch, max_height_inch*units.inch))
664
665
        for info in self._image_infos:
666
            width_inch = float(info['width_pixel'])/info['dpi']
667
            height_inch = float(info['height_pixel'])/info['dpi']
668
            canvas.setFillColorRGB(1, 1, 1)
669
            canvas.rect(0, 0, max_width_inch*units.inch,
670
                max_height_inch*units.inch, fill=1)
671
            canvas.drawImage(info['file'].name, 0, 0, width_inch*units.inch,
672
                height_inch*units.inch)
673
            canvas.showPage()
674
675
        canvas.save()
676
677
    @trace()
678
    def _save_reply_cb(self, *args):
679
        [info['file'].close() for info in self._image_infos]
680
        self._image_infos = []
681
682
    @trace()
683
    def _save_error_cb(self, error):
684
        self._show_error(error)
685
        self._save_button.set_sensitive(True)
686
687
    @trace()
688
    def _show_error(self, exc):
689
        self._add_msg(exc)
690
        self._set_status('error', exc)
691
692
    @trace()
693
    def _add_msg(self, msg):
694
        logging.info('%s', msg)
695
        self._msg_box_buf.insert(self._msg_box_buf.get_end_iter(),
696
            '%.1f %s\n' % (time.time(), msg))
697
698
    @trace()
699
    def _set_status(self, status, *args):
700
        self._status = status
701
        self._status_entry.set_text(self._STATUS_MSGS[status] % tuple(args))
702
703
        if self._status == 'ready':
704
            self._scan_button.set_icon('start-scan1')
705
            self._scan_button.props.tooltip = _('Start scan')
706
            self._scan_button.set_sensitive(True)
707
        elif self._status == 'scanning':
708
            self._scan_button.set_icon('stop-scan1')
709
            self._scan_button.props.tooltip = _('Stop scan')
710
            self._scan_button.set_sensitive(True)
711
        else:
712
            self._scan_button.set_sensitive(False)
713
714
715
def start():
716
    gtk.gdk.threads_init()
717
    gtk.gdk.threads_enter()
718
719
# FIXME: can't call gtk.gdk.threads_leave() because sugar-activity
720
# (sugar.activity.main.main()) doesn't provide a hook for that. :(