1
2
3 """
4 S3Codec to produce printable data cards (e.g. ID cards)
5
6 @copyright: 2018-2019 (c) Sahana Software Foundation
7 @license: MIT
8
9 Permission is hereby granted, free of charge, to any person
10 obtaining a copy of this software and associated documentation
11 files (the "Software"), to deal in the Software without
12 restriction, including without limitation the rights to use,
13 copy, modify, merge, publish, distribute, sublicense, and/or sell
14 copies of the Software, and to permit persons to whom the
15 Software is furnished to do so, subject to the following
16 conditions:
17
18 The above copyright notice and this permission notice shall be
19 included in all copies or substantial portions of the Software.
20
21 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
22 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
23 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
24 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
25 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
26 WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
27 FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
28 OTHER DEALINGS IN THE SOFTWARE.
29 """
30
31 __all__ = ("S3PDFCard",
32 )
33
34 try:
35 from cStringIO import StringIO
36 except ImportError:
37 from StringIO import StringIO
38
39 try:
40 from reportlab.lib.pagesizes import A4, LETTER, landscape, portrait
41 from reportlab.platypus import BaseDocTemplate, PageTemplate, Flowable, \
42 Frame, NextPageTemplate, PageBreak
43 from reportlab.lib.utils import ImageReader
44 from reportlab.graphics import renderPDF
45 from reportlab.graphics.barcode import code39, code128, qr
46 from reportlab.graphics.shapes import Drawing
47 REPORTLAB = True
48 except ImportError:
49 BaseDocTemplate = object
50 Flowable = object
51 REPORTLAB = False
52
53 from gluon import current, HTTP
54
55 from ..s3codec import S3Codec
56 from ..s3resource import S3Resource
57 from ..s3utils import s3_str
58
59 CREDITCARD = (153, 243)
62 """
63 Codec to produce printable data cards (e.g. ID cards)
64 """
65
66 - def encode(self, resource, **attr):
67 """
68 API Method to encode a resource as cards
69
70 @param resource: the S3Resource, or
71 - the data items as list [{fieldname: representation, ...}, ...], or
72 - a callable that produces such a list of items
73 @param attr: additional encoding parameters (see below)
74
75 @keyword layout: the layout (a S3PDFCardLayout subclass, overrides
76 the resource's pdf_card_layout setting
77 @keyword orderby: orderby-expression for data extraction, overrides
78 the resource's orderby setting
79 @keyword labels: the labels for the fields,
80 - a dict {colname: label}, or
81 - a callable that produces it,
82 - defaults to the labels of the extracted fields
83 @keyword pagesize: the PDF page size,
84 - a string "A4" or "Letter", or
85 - a tuple (width, height), in points
86 - defaults to the layout's card size
87 @keyword margins: the page margins,
88 - a tuple (N, E, S, W), in points, or
89 - a single number, in points
90 - will be computed if omitted
91 @keyword spacing: the spacing between cards,
92 - a tuple (H, V), in points, or
93 - a single number, in points
94 - defaults to 18 points in both directions
95 @keyword title: the document title,
96 - defaults to title_list crud string of the resource
97
98 @return: a handle to the output
99 """
100
101 if not REPORTLAB:
102
103 raise HTTP(503, "Python ReportLab library not installed")
104
105
106 is_resource = isinstance(resource, S3Resource)
107
108
109 layout = attr.get("layout")
110 if layout is None and is_resource:
111 layout = resource.get_config("pdf_card_layout")
112 if layout is None:
113 layout = S3PDFCardLayout
114
115
116 orientation = layout.orientation
117 if orientation == "Landscape":
118 orientation = landscape
119 else:
120 orientation = portrait
121
122
123 cardsize = orientation(layout.cardsize)
124 pagesize = attr.get("pagesize")
125 if pagesize == "A4":
126 pagesize = A4
127 elif pagesize == "Letter":
128 pagesize = LETTER
129 elif not isinstance(pagesize, (tuple, list)):
130 pagesize = cardsize
131 pagesize = orientation(pagesize)
132
133
134 if is_resource:
135
136 fields = layout.fields(resource)
137 data = self.extract(resource, fields, orderby=attr.get("orderby"))
138 items = data.rows
139 elif callable(resource):
140
141 data = None
142 items = resource()
143 else:
144
145 data = None
146 items = resource
147
148
149 labels = attr.get("labels")
150 if callable(labels):
151 labels = labels(resource)
152 elif not isinstance(labels, dict):
153 if data and hasattr(data, "rfields"):
154
155 rfields = data.rfields
156 labels = {rfield.colname: rfield.label for rfield in rfields}
157 else:
158 labels = {}
159
160
161
162 title = attr.get("title")
163 if not title and is_resource:
164 crud_strings = current.response.s3.crud_strings[resource.tablename]
165 if crud_strings:
166 title = crud_strings["title_list"]
167
168
169 doc = S3PDFCardTemplate(pagesize,
170 cardsize,
171 margins = attr.get("margins"),
172 spacing = attr.get("spacing"),
173 title = title,
174 )
175
176
177 flowables = self.get_flowables(layout,
178 resource,
179 items,
180 labels = labels,
181 cards_per_page = doc.cards_per_page,
182 )
183
184
185 output_stream = StringIO()
186 doc.build(flowables,
187 output_stream,
188
189 )
190
191 output_stream.seek(0)
192 return output_stream
193
194
196 """
197 Extract the data items from the given resource
198
199 @param resource: the resource (a filtered S3Resource)
200 @param fields: the fields to extract (array of field selectors)
201 @param orderby: the orderby-expression
202
203 @returns: an S3ResourceData instance
204 """
205
206 if orderby is None:
207 orderby = resource.get_config("orderby")
208 if orderby is None:
209 orderby = resource._id
210
211 return resource.select(fields,
212 represent = True,
213 show_links = False,
214 raw_data = True,
215 orderby = orderby,
216 )
217
218
219 - def get_flowables(self, layout, resource, items, labels=None, cards_per_page=1):
220 """
221 Get the Flowable-instances for the data items
222
223 @param layout: the S3PDFCardLayout subclass implementing the
224 card layout
225 @param resource: the resource
226 @param items: the data items
227 @param labels: the field labels
228 @param cards_per_page: the number of cards per page
229 """
230
231 if not len(items):
232
233 return [PageBreak()]
234
235
236 number_of_pages = int(len(items) / cards_per_page) + 1
237 multiple = cards_per_page > 1
238
239
240 common = layout.lookup(resource, items)
241
242
243 flowables = []
244 append = flowables.append
245 for i in range(number_of_pages):
246
247
248 batch = items[i * cards_per_page:(i+1) * cards_per_page]
249 if not batch:
250 continue
251
252
253 append(NextPageTemplate("Front"))
254 if i > 0:
255 append(PageBreak())
256 for item in batch:
257 append(layout(resource,
258 item,
259 labels = labels,
260 common = common,
261 multiple = multiple,
262 ))
263
264 if layout.doublesided:
265
266 append(NextPageTemplate("Back"))
267 append(PageBreak())
268 for item in batch:
269 append(layout(resource,
270 item,
271 labels = labels,
272 common = common,
273 multiple = multiple,
274 backside = True,
275 ))
276
277 return flowables
278
281 """
282 Document Template for data cards
283 """
284
285 - def __init__(self,
286 pagesize,
287 cardsize,
288 margins = None,
289 spacing = None,
290 title = None,
291 ):
292 """
293 Constructor
294
295 @param pagesize: the page size, tuple (w, h)
296 @param cardsize: the card size, tuple (w, h)
297 @param margins: the page margins, tuple (N, E, S, W)
298 @param spacing: the spacing between cards, tuple (H, V)
299 @param title: the document title
300
301 - all sizes in points (72 points per inch)
302 """
303
304
305 if spacing is None:
306 spacing = (18, 18)
307 elif not isinstance(spacing, (tuple, list)):
308 spacing = (spacing, spacing)
309
310
311 if margins is None:
312 margins = self.compute_margins(pagesize, cardsize, spacing)
313 elif not isinstance(margins, (tuple, list)):
314 margins = (margins, margins, margins, margins)
315
316
317 pagewidth, pageheight = pagesize
318 cardwidth, cardheight = cardsize
319
320 number_of_cards = self.number_of_cards
321
322 cards_per_row = number_of_cards(pagewidth,
323 cardwidth,
324 (margins[1], margins[3]),
325 spacing[0],
326 )
327
328 rows_per_page = number_of_cards(pageheight,
329 cardheight,
330 (margins[0], margins[2]),
331 spacing[1],
332 )
333
334 self.cards_per_row = cards_per_row
335 self.rows_per_page = rows_per_page
336 self.cards_per_page = rows_per_page * cards_per_row
337
338
339 pages = self.page_layouts(pagesize, cardsize, margins, spacing)
340
341 if title is None:
342 title = current.T("Items")
343
344
345 BaseDocTemplate.__init__(self,
346 None,
347 pagesize = pagesize,
348 pageTemplates = pages,
349 topMargin = margins[0],
350 rightMargin = margins[1],
351 bottomMargin = margins[2],
352 leftMargin = margins[3],
353 title = s3_str(title),
354 )
355
356
358 """
359 Compute the number of cards for one page dimension
360
361 @param pagesize: the page size
362 @param cardsize: the card size
363 @param margins: tuple of margins
364 @param spacing: the spacing between cards
365 """
366
367 available = pagesize - sum(margins)
368 if available < cardsize:
369 available = pagesize
370 if available < cardsize:
371 return 0
372 return int((available + spacing) / (cardsize + spacing))
373
374
376 """
377 Calculate default margins
378
379 @param pagesize: the page size, tuple (w, h)
380 @param cardsize: the card size, tuple (w, h)
381 @param spacing: spacing between cards, tuple (h, v)
382 """
383
384 cardwidth, cardheight = cardsize
385 pagewidth, pageheight = pagesize
386 spacing_h, spacing_v = spacing
387
388
389 number_of_cards = self.number_of_cards
390 numh = number_of_cards(pagewidth, cardwidth, (18, 18), spacing_h)
391 numv = number_of_cards(pageheight, cardheight, (12, 12), spacing_v)
392
393
394 width = (numh - 1) * (cardwidth + spacing_h) + cardwidth
395 height = (numv - 1) * (cardheight + spacing_v) + cardheight
396
397
398 margin_h = (pagewidth - width) / 2
399 margin_v = (pageheight - height) / 2
400
401 return (margin_v, margin_h, margin_v, margin_h)
402
403
404 - def page_layouts(self, pagesize, cardsize, margins, spacing):
405 """
406 Generate page templates for front/back sides of cards
407
408 @param pagesize: the page size, tuple (w, h)
409 @param cardsize: the card size, tuple (w, h)
410 @param margins: the page margins, tuple (N, E, S, W)
411 @param spacing: the spacing between cards, tuple (H, V)
412
413 - all sizes in points (72 points per inch)
414 """
415
416 pagewidth, pageheight = pagesize
417 cardwidth, cardheight = cardsize
418
419 topmargin, leftmargin = margins[0], margins[3]
420
421 hspacing, vspacing = spacing
422
423
424 y0 = pageheight - topmargin - cardheight
425
426
427
428
429
430 x0_front = leftmargin
431 x0_back = pagewidth - leftmargin - cardwidth
432
433 fframes, bframes = [], []
434 for i in range(self.rows_per_page):
435
436
437 y = y0 - i * (vspacing + cardheight)
438
439
440 for j in range(self.cards_per_row):
441
442
443 fframes.append(Frame(x0_front + j * (cardwidth + hspacing),
444 y,
445 cardwidth,
446 cardheight,
447 topPadding = 0,
448 rightPadding = 0,
449 bottomPadding = 0,
450 leftPadding = 0,
451 ))
452
453
454 bframes.append(Frame(x0_back - j * (cardwidth + hspacing),
455 y,
456 cardwidth,
457 cardheight,
458 topPadding = 0,
459 rightPadding = 0,
460 bottomPadding = 0,
461 leftPadding = 0,
462 ))
463
464
465 return [PageTemplate(id="Front", frames = fframes),
466 PageTemplate(id="Back", frames = bframes),
467 ]
468
471 """
472 Flowable base class for data cards, to be subclassed per use-case;
473 subclasses should implement the draw()-method to render a data item.
474 """
475
476
477 cardsize = CREDITCARD
478 orientation = "Portrait"
479 doublesided = True
480
481 - def __init__(self,
482 resource,
483 item,
484 labels=None,
485 common=None,
486 backside=False,
487 multiple=False,
488 ):
489 """
490 Constructor
491
492 @param resource: the resource
493 @param item: the data item
494 @param labels: the field labels
495 @param common: common data for all cards
496 @param backside: this instance should render a card backside
497 @param multiple: there are multiple cards per page
498 """
499
500 Flowable.__init__(self)
501
502 self.width, self.height = self.cardsize
503
504 self.resource = resource
505 self.item = item
506
507 self.labels = labels if labels is not None else {}
508 self.common = common if common is not None else {}
509
510 self.backside = backside
511 self.multiple = multiple
512
513
515 """
516 Draw the card (one side), to be implemented by subclass
517
518 Instance attributes (NB draw-function should not modify them):
519 - self.canv...............the canvas (provides the drawing methods)
520 - self.resource...........the resource
521 - self.item...............the data item (dict)
522 - self.labels.............the field labels (dict)
523 - self.backside...........this instance should render the backside
524 of a card
525 - self.multiple...........there are multiple cards per page
526 - self.width..............the width of the card (in points)
527 - self.height.............the height of the card (in points)
528
529 NB Canvas coordinates are relative to the lower left corner of the
530 card's frame, drawing must not overshoot self.width/self.height
531 """
532
533 c = self.canv
534
535 w = self.width
536 h = self.height
537
538 c.setDash(1, 2)
539 self.draw_outline()
540
541 x = w / 2
542 y = h / 2
543
544 c.setFont("Helvetica", 12)
545 c.drawCentredString(x, y, "Back" if self.backside else "Front")
546
547 resource = self.resource
548 if isinstance(resource, S3Resource):
549
550 item = self.item
551 pid = str(resource._id)
552 if pid in item:
553 c.setFont("Helvetica", 8)
554 c.drawCentredString(x, y - 10, "Record #%s" % item[pid])
555
556
557 @classmethod
559 """
560 Get the fields to look up from the resource, can be overridden
561 in subclasses (as the field list is usually layout-specific)
562
563 @param resource: the resource
564
565 @returns: list of field selectors
566 """
567
568 return resource.list_fields()
569
570
571 @classmethod
572 - def lookup(cls, resource, items):
573 """
574 Look up common data for all cards
575
576 @param resource: the resource
577 @param items: the items
578
579 @returns: a dict with common data for all cards, will be
580 passed to the individual flowables
581 """
582
583 return {}
584
585
586 - def draw_barcode(self,
587 value,
588 x,
589 y,
590 bctype="code128",
591 height=12,
592 barwidth=0.85,
593 halign=None,
594 valign=None,
595 maxwidth=None,
596 ):
597 """
598 Helper function to render a barcode
599
600 @param value: the string to encode
601 @param x: drawing position
602 @param y: drawing position
603 @param bctype: the barcode type
604 @param height: the height of the barcode (in points)
605 @param barwidth: the default width of the smallest bar
606 @param halign: horizontal alignment ("left"|"center"|"right"), default left
607 @param valign: vertical alignment ("top"|"middle"|"bottom"), default bottom
608 @param maxwidth: the maximum total width, if specified, the barcode will
609 not be rendered if it would exceed this width even with
610 the minimum possible bar width
611
612 @return: True if successful, otherwise False
613 """
614
615
616
617 types = {"code39": code39.Standard39,
618 "code128": code128.Code128,
619 }
620
621 encode = types.get(bctype)
622 if not encode:
623 raise RuntimeError("Barcode type %s not supported" % bctype)
624 else:
625 qz = 12 * barwidth
626 barcode = encode(value,
627 barHeight = height,
628 barWidth = barwidth,
629 lquiet = qz,
630 rquiet = qz,
631 )
632
633 width, height = barcode.width, barcode.height
634 if maxwidth and width > maxwidth:
635
636 bw = max(float(maxwidth) / width * barwidth, encode.barWidth)
637 qz = 12 * bw
638 barcode = encode(value,
639 barHeight = height,
640 barWidth = bw,
641 lquiet = qz,
642 rquiet = qz,
643 )
644 width = barcode.width
645 if width > maxwidth:
646 return False
647
648 hshift = vshift = 0
649 if halign == "right":
650 hshift = width
651 elif halign == "center":
652 hshift = width / 2.0
653
654 if valign == "top":
655 vshift = height
656 elif valign == "middle":
657 vshift = height / 2.0
658
659 barcode.drawOn(self.canv, x - hshift, y - vshift)
660 return True
661
662
663 - def draw_qrcode(self, value, x, y, size=40, halign=None, valign=None):
664 """
665 Helper function to draw a QR code
666
667 @param value: the string to encode
668 @param x: drawing position
669 @param y: drawing position
670 @param size: the size (edge length) of the QR code
671 @param halign: horizontal alignment ("left"|"center"|"right"), default left
672 @param valign: vertical alignment ("top"|"middle"|"bottom"), default bottom
673 """
674
675 qr_code = qr.QrCodeWidget(value)
676
677 bounds = qr_code.getBounds()
678 w = bounds[2] - bounds[0]
679 h = bounds[3] - bounds[1]
680
681 transform = [float(size) / w, 0, 0, float(size) / h, 0, 0]
682 d = Drawing(size, size, transform=transform)
683 d.add(qr_code)
684
685 hshift = vshift = 0
686 if halign == "right":
687 hshift = size
688 elif halign == "center":
689 hshift = float(size) / 2.0
690
691 if valign == "top":
692 vshift = size
693 elif valign == "middle":
694 vshift = float(size) / 2.0
695
696 renderPDF.draw(d, self.canv, x - hshift, y - vshift)
697
698
699 - def draw_image(self,
700 img,
701 x,
702 y,
703 width=None,
704 height=None,
705 proportional=True,
706 scale=None,
707 halign=None,
708 valign=None,
709 ):
710 """
711 Helper function to draw an image
712 - requires PIL (required for ReportLab image handling anyway)
713
714 @param img: the image (filename or StringIO buffer)
715 @param x: drawing position
716 @param y: drawing position
717 @param width: the target width of the image (in points)
718 @param height: the target height of the image (in points)
719 @param proportional: keep image proportions when scaling to width/height
720 @param scale: scale the image by this factor (overrides width/height)
721 @param halign: horizontal alignment ("left"|"center"|"right"), default left
722 @param valign: vertical alignment ("top"|"middle"|"bottom"), default bottom
723 """
724
725 if hasattr(img, "seek"):
726 is_buffer = True
727 img.seek(0)
728 else:
729 is_buffer = False
730
731 try:
732 from PIL import Image as pImage
733 except ImportError:
734 current.log.error("Image rendering failed: PIL not installed")
735 return
736
737 pimg = pImage.open(img)
738 img_size = pimg.size
739
740 if not img_size[0] or not img_size[1]:
741
742 return
743
744
745 if scale:
746 width = img_size[0] * scale
747 height = img_size[1] * scale
748 elif width and height:
749 if proportional:
750 scale = min(float(width) / img_size[0], float(height) / img_size[1])
751 width = img_size[0] * scale
752 height = img_size[1] * scale
753 elif width:
754 height = img_size[1] * (float(width) / img_size[0])
755 elif height:
756 width = img_size[0] * (float(height) / img_size[1])
757 else:
758 width = img_size[0]
759 height = img_size[1]
760
761
762 hshift = vshift = 0
763 if halign == "right":
764 hshift = width
765 elif halign == "center":
766 hshift = width / 2.0
767
768 if valign == "top":
769 vshift = height
770 elif valign == "middle":
771 vshift = height / 2.0
772
773
774 if is_buffer:
775 img.seek(0)
776 ir = ImageReader(img)
777
778 c = self.canv
779 c.drawImage(ir,
780 x - hshift,
781 y - vshift,
782 width = width,
783 height = height,
784 preserveAspectRatio = proportional,
785 mask = "auto",
786 )
787
788
790 """
791 Helper function to draw the outline of the card, useful as cutting
792 line when there are multiple cards per page
793 """
794
795 c = self.canv
796
797 c.setLineWidth(1)
798 c.rect(-1, -1, self.width + 2, self.height + 2)
799
800
801