@@ -403,7 +403,7 @@ def headers( self ):
403403 """Output the headers to use in tabular formatting of iterator. By default, we'd recommend hiding any starting
404404 with an _ prefix."""
405405 return (
406- 'Line ' ,
406+ '_# ' ,
407407 'Description' ,
408408 'Units' ,
409409 'Price' ,
@@ -413,14 +413,14 @@ def headers( self ):
413413 'Net' ,
414414 'Amount' ,
415415 'Currency' ,
416- 'Symbol ' ,
416+ '_Symbol ' ,
417417 '_Decimals' ,
418418 '_Token' ,
419419 ) + tuple (
420- f"Total { currency } "
420+ f"_Total { currency } "
421421 for currency in sorted ( self .currencies )
422422 ) + tuple (
423- f"Taxes { currency } "
423+ f"_Taxes { currency } "
424424 for currency in sorted ( self .currencies )
425425 )
426426
@@ -530,20 +530,31 @@ def tables(
530530 Each interior page includes a subtotals table; the final page also includes the full totals.
531531
532532 The 'columns' is a filter (a set/list/tuple or a predicate) that selects the columns
533- desired; by default, elides any columns having names starting with '_'.
533+ desired; by default, elides any columns having names starting with '_'. We will accept
534+ matches case-insensively, and ignoring any leading/trailing '_'.
535+
536+ If any columns w/ leading '_' *are* selection, we trim the '_' for display.
534537
535538 """
536539 headers = self .headers ()
540+
541+ def can ( c ):
542+ """Canonicalize a header/column name for comparison"""
543+ return c .strip ( '_' ).lower ()
544+
545+ headers_can = [ can ( h ) for h in headers ]
537546 if columns :
538547 if is_listlike ( columns ):
539- selected = tuple ( self .headers ().index ( h ) for h in columns )
540- assert not any ( i < 0 for i in selected ), \
541- f"Columns not found: { commas ( h for h in headers if h not in columns )} "
548+ try :
549+ selected = tuple ( headers_can .index ( can ( c )) for c in columns )
550+ except ValueError as exc :
551+ raise ValueError ( f"Columns not found: { commas ( c for c in columns if can ( c ) not in headers_can )} " ) from exc
542552 elif hasattr ( columns , '__contains__' ):
543- selected = tuple ( i for i ,h in enumerate ( headers ) if h in columns )
553+ selected = tuple ( i for i ,h in enumerate ( headers ) if can ( h ) in [ can ( c ) for c in columns ] )
544554 assert selected , \
545555 "No columns matched"
546556 else :
557+ # User-provided column predicate; do not canonicalize
547558 selected = tuple ( i for i ,h in enumerate ( headers ) if columns ( h ) )
548559 else :
549560 # Default; just ignore _... columns
@@ -554,30 +565,31 @@ def tables(
554565 pages = list ( self .pages ( * args , ** kwds ))
555566 # Overall formatted decimals for entire invoice; use the greatest desired decimals of any
556567 # token/line. Individual line items may have been rounded to lower decimals.
557- decimals_i = headers .index ( '_Decimals' )
568+ decimals_i = headers_can .index ( can ( '_Decimals' ) )
558569 decimals = max ( line [decimals_i ] for page in pages for line in page )
559- floatfmt = f" ,.{ decimals } f"
560- intfmt = ","
570+ floatfmt = f' ,.{ decimals } f'
571+ intfmt = ','
561572 page_prev = None
562573 for p ,page in enumerate ( pages ):
563574 table = tabulate (
564575 # Tabulate the line items
565576 [[line [i ] for i in selected ] for line in page ],
566- headers = headers_selected ,
577+ headers = [ h . strip ( '_' ) for h in headers_selected ] ,
567578 intfmt = intfmt ,
568579 floatfmt = floatfmt ,
569580 tablefmt = tablefmt or 'orgtbl' ,
570581 )
571582 first = page_prev is None
583+ final = p + 1 == len ( pages )
572584
573585 def deci ( c ):
574586 return self .currencies_proxy [c ].decimals // 3
575587
576588 def toti ( c ):
577- return headers .index ( f'Total { c } ' )
589+ return headers_can .index ( can ( f'_Total { c } ' ) )
578590
579591 def taxi ( c ):
580- return headers .index ( f'Taxes { c } ' )
592+ return headers_can .index ( can ( f'_Taxes { c } ' ) )
581593
582594 subtotal = tabulate (
583595 # And the per-currency Sub-totals (for each page)
@@ -595,9 +607,15 @@ def taxi( c ):
595607 ]
596608 for c in sorted ( self .currencies )
597609 ],
598- headers = ( "Account" , "Crypto" , "Currency" , f"Subtotal { p + 1 } /{ len ( pages )} " , f"Subtotal { p + 1 } /{ len ( pages )} Taxes" ),
599- intfmt = "," ,
600- floatfmt = ",g" ,
610+ headers = (
611+ 'Account' ,
612+ 'Crypto' ,
613+ 'Currency' ,
614+ 'Subtotal' if final else f'Subtotal { p + 1 } /{ len ( pages )} ' ,
615+ 'Taxes' if final else f'Taxes { p + 1 } /{ len ( pages )} '
616+ ),
617+ intfmt = ',' ,
618+ floatfmt = ',g' ,
601619 tablefmt = tablefmt or 'orgtbl' ,
602620 )
603621 total = tabulate (
@@ -612,18 +630,24 @@ def taxi( c ):
612630 ]
613631 for c in sorted ( self .currencies )
614632 ],
615- headers = ( "Account" , "Crypto" , "Currency" , f"Total { p + 1 } /{ len ( pages )} " , f"Total { p + 1 } /{ len ( pages )} Taxes" ),
616- intfmt = "," ,
617- floatfmt = ",g" ,
633+ headers = (
634+ 'Account' ,
635+ 'Crypto' ,
636+ 'Currency' ,
637+ 'Total' if final else f'Total { p + 1 } /{ len ( pages )} ' ,
638+ 'Taxes' if final else f'Taxes { p + 1 } /{ len ( pages )} '
639+ ),
640+ intfmt = ',' ,
641+ floatfmt = ',g' ,
618642 tablefmt = tablefmt or 'orgtbl' ,
619643 )
620644
621645 yield page , table , subtotal , total
622646
623647
624648def layout_invoice (
625- inv_dim : Coordinate ,
626- inv_margin : Optional [ int ],
649+ inv_dim : Coordinate , # Printable invoice dimensions, in inches (net page margins).
650+ inv_margin : int , # Additional margin around invoice
627651 rows : int ,
628652):
629653 """Layout an Invoice, in portrait format.
@@ -783,7 +807,6 @@ def produce_invoice(
783807 rows : Optional [int ] = None ,
784808 paper_format : Any = None , # 'Letter', 'Legal', 'A4', (x,y) dimensions in mm.
785809 orientation : Optional [str ] = None , # available orientations; default portrait, landscape
786- inv_margin : Optional [int ] = None ,
787810 inv_image : Optional [Union [Path ,str ]] = None , # A custom background image (Path or name relative to directory/'.'
788811):
789812 """Produces a PDF containing the supplied Invoice details, optionally with a PAID watermark.
@@ -794,12 +817,14 @@ def produce_invoice(
794817 if isinstance ( directory , (str ,type (None ))):
795818 directory = Path ( directory or '.' )
796819 assert isinstance ( directory , (Path ,type (None )))
820+ log .info ( f"Dir.: { directory } " )
797821
798822 if isinstance ( inv_image , (str ,type (None ))):
799823 inv_image = directory / ( inv_image or 'inv-image.*' )
800824 if not inv_image .exists ():
801825 inv_image = None
802826 assert isinstance ( inv_image , (Path ,type (None )))
827+ log .info ( f"Image: { inv_image } " )
803828
804829 # Any datetime WITHOUT a timezone designation is re-interpreted as the local timezone of the
805830 # invoice issuer, or UTC.
@@ -812,7 +837,7 @@ def produce_invoice(
812837 inv_zone = timezone .utc
813838 inv_date = inv_date .astimezone ( inv_zone )
814839
815- log .info ( f"Date: { inv_date .strftime ( INVOICE_STRFTIME )} " )
840+ log .info ( f"Date: { inv_date .strftime ( INVOICE_STRFTIME )} " )
816841 if not isinstance ( inv_due , datetime ):
817842 if not inv_due :
818843 inv_due = INVOICE_DUE
@@ -824,15 +849,15 @@ def produce_invoice(
824849 inv_due = inv_date + inv_due
825850 else :
826851 raise ValueError ( f"Unsupported Invoice due date: { inv_due !r} " )
827- log .info ( f"Due: { inv_due .strftime ( INVOICE_STRFTIME )} " )
852+ log .info ( f"Due: { inv_due .strftime ( INVOICE_STRFTIME )} " )
828853 if inv_number is None :
829854 cli = client .name .split ()[0 ].upper ()[:3 ] if client else 'INV'
830855 ymd = inv_date .strftime ( '%Y%m%d' )
831856 key = f"{ cli } -{ ymd } "
832857 produce_invoice .inv_count [key ] += 1
833858 num = produce_invoice .inv_count [key ]
834859 inv_number = f"{ cli } -{ ymd } -{ num :04d} "
835- log .info ( f"Num.: { inv_number } " )
860+ log .info ( f"Num.: { inv_number } " )
836861
837862 metadata = InvoiceMetadata (
838863 vendor = vendor ,
@@ -846,17 +871,20 @@ def produce_invoice(
846871 if conversions is None :
847872 conversions = {}
848873
849- # Default to full page, given the desired paper and orientation
850- invs_pp ,orientation ,page_xy ,pdf ,inv_dim = layout_pdf (
874+ # Default to full page, given the desired paper and orientation. All PDF dimensions are mm.
875+ invs_pp ,orientation ,page_xy ,pdf ,comp_dim = layout_pdf (
851876 paper_format = paper_format ,
852877 orientation = orientation ,
853878 )
854- log .info ( f'Dim.: { inv_dim .x / MM_IN :6.3f} " x { inv_dim .y / MM_IN :6.3f} "' )
879+ log .info ( f'Dim.: { comp_dim .x / MM_IN :6.3f} " x { comp_dim .y / MM_IN :6.3f} "' )
855880
856881 # TODO: compute rows based on line lengths; the longer the line, the smaller the lines required
857882 if rows is None :
858883 rows = INVOICE_ROWS
859- inv = layout_invoice ( inv_dim = inv_dim , inv_margin = inv_margin , rows = rows )
884+
885+ # Compute the Invoice layout on the page. All page layouts are specified in inches.
886+ inv_dim = Coordinate ( comp_dim .x / MM_IN , comp_dim .y / MM_IN )
887+ inv = layout_invoice ( inv_dim = inv_dim , inv_margin = 0 , rows = rows )
860888
861889 inv_elements = list ( inv .elements () )
862890 if log .isEnabledFor ( logging .DEBUG ):
0 commit comments