File Coverage

blib/lib/Text/PageLayout.pm
Criterion Covered Total %
statement 73 73 100.0
branch 18 20 90.0
condition 2 3 66.6
subroutine 10 10 100.0
pod 1 2 50.0
total 104 108 96.3


line stmt bran cond sub pod time code
1             package Text::PageLayout;
2              
3 4     4   99634 use utf8;
  4         17  
  4         26  
4 4     4   194 use 5.010;
  4         11  
  4         155  
5 4     4   21 use strict;
  4         10  
  4         137  
6 4     4   20 use warnings;
  4         6  
  4         214  
7              
8             our $VERSION = '0.05';
9              
10 4     4   2019 use Text::PageLayout::Page;
  4         12  
  4         141  
11 4     4   34 use Moo;
  4         8  
  4         17  
12 4     4   1136 use Scalar::Util qw/reftype/;
  4         9  
  4         4765  
13              
14             with 'Text::PageLayout::PageElements';
15              
16             has page_size => (
17             is => 'rw',
18             default => sub { 67 },
19             );
20              
21             has tolerance => (
22             is => 'rw',
23             default => sub { 6 },
24             );
25              
26             has fillup_pages => (
27             is => 'rw',
28             default => sub { 1 },
29             );
30              
31             has split_paragraph => (
32             is => 'rw',
33             default => sub {
34             sub {
35             my %param = @_;
36             my @lines = split /\n/, $param{paragaraph}, $param{max_lines} + 1;
37             my $last = pop @lines;
38             return (
39             join("", map "$_\n", @lines),
40             $last,
41             );
42             };
43             },
44             );
45              
46             sub line_count {
47 57     57 0 87 my ($self, $str) = @_;
48 57         87 my $cnt = $str =~ tr/\n//;
49 57         143 return $cnt;
50             }
51              
52             sub pages {
53 5     5 1 756 my $self = shift;
54 5         9 my @pars = @{ $self->paragraphs };
  5         55  
55 5         19 my $separator = $self->separator;
56 5         26 my $sep_lines = $self->line_count($separator);
57 5         16 my $tolerance = $self->tolerance;
58 5         16 my $goal = $self->page_size;
59 5         10 my $current_page = 1;
60 5         19 my $header = $self->_get_elem('header', $current_page);
61 5         15 my $footer = $self->_get_elem('footer', $current_page);
62 5         15 my $lines_used = $self->line_count($header) + $self->line_count($footer);
63              
64 5         10 my @pages;
65             my @current_pars;
66              
67 5         24 while (@pars) {
68 22         46 my $paragraph = shift @pars;
69 22         60 my $l = $self->line_count($paragraph);
70              
71 22         34 my $start_new_page = 0;
72             # for a page with a single paragraph, we have no separation
73             # lines, so the effective height introduce by separator is 0
74 22 100       55 my $effective_sep_lines = @current_pars ? $sep_lines : 0;
75              
76 22 100       64 if ( $lines_used + $l + $effective_sep_lines <= $goal ) {
    100          
77             # use the paragraph
78 14         21 $lines_used += $l + $effective_sep_lines;
79 14         21 push @current_pars, $paragraph;
80             }
81             elsif ( $lines_used + $tolerance >= $goal) {
82             # start a new page, re-schedule the current paragraph
83 2         3 $start_new_page = 1;
84 2         5 unshift @pars, $paragraph;
85             }
86             else {
87             # no such luck; start a new page, potentially by
88             # splitting the paragraph
89 6         11 $start_new_page = 1;
90 6         41 my ($c1, $c2) = $self->split_paragraph->(
91             paragraph => $paragraph,
92             max_lines => $goal - $lines_used - $effective_sep_lines,
93             page_number => $current_page,
94             );
95 6         159 my $c1_lines = $self->line_count($c1);
96 6 100       24 if ($c1_lines + $lines_used + $effective_sep_lines <= $goal) {
    50          
97             # accept the split
98 5         7 $lines_used += $c1_lines + $effective_sep_lines;
99 5         12 push @current_pars, $c1;
100             # re-schedule the second chunk
101 5         15 unshift @pars, $c2;
102             }
103             elsif (!@current_pars) {
104 1         12 my $message = sprintf "Paragraph too long even after splitting (%d lines) to fit on a page (max height %d, header and foot take up %d lines in total)\n",
105             $c1_lines,
106             $goal,
107             $lines_used;
108 1         14 require Carp;
109 1         317 Carp::croak($message);
110             }
111             }
112 21 100       66 if ($start_new_page) {
113 7 100       1038 push @pages, Text::PageLayout::Page->new(
114             paragraphs => [@current_pars],
115             page_number => $current_page,
116             header => $header,
117             footer => $footer,
118             process_template => $self->process_template,
119             bottom_filler => $self->fillup_pages
120             ? "\n" x ($goal - $lines_used)
121             : '',
122             separator => $separator,
123             );
124 7         7579 $current_page++;
125 7         19 @current_pars = ();
126 7         26 $header = $self->_get_elem('header', $current_page);
127 7         39 $footer = $self->_get_elem('footer', $current_page);
128 7         23 $lines_used = $self->line_count($header) + $self->line_count($footer);
129             }
130             }
131 4 50       16 if (@current_pars) {
132             # final page
133 4 100       141 push @pages, Text::PageLayout::Page->new(
134             paragraphs => [@current_pars],
135             page_number => $current_page,
136             header => $header,
137             footer => $footer,
138             process_template => $self->process_template,
139             bottom_filler => $self->fillup_pages
140             ? "\n" x ($goal - $lines_used)
141             : '',
142             separator => $separator,
143             );
144             }
145 4         246 for my $p (@pages) {
146 11         44 $p->total_pages($current_page);
147             }
148 4         39 return @pages;
149             }
150              
151             sub _get_elem {
152 24     24   72 my ($self, $elem, $page) = @_;
153 24         77 my $e = $self->$elem();
154 24 100 66     93 if (ref $e && reftype($e) eq 'CODE') {
155 4         11 $e = $e->(page_number => $page);
156             }
157 24         79 return $e;
158             }
159              
160             =head1 NAME
161              
162             Text::PageLayout - Distribute paragraphs onto pages, with headers and footers.
163              
164             =head1 SYNOPSIS
165              
166             use 5.010;
167             use Text::PageLayout;
168              
169             my @paragraphs = ("a\nb\nc\nd\n") x 6,
170              
171             # simple example
172             my $layout = Text::PageLayout->new(
173             page_size => 20, # number of lines per page
174             header => "head\n",
175             footer => "foot\n",
176             paragraphs => \@paragraphs, # REQUIRED
177             );
178              
179             for my $p ( $layout->pages ) {
180             say "Page No. ", $p->page_number;
181             say $p;
182             }
183              
184             # more complex example:
185             sub header {
186             my %param = @_;
187             if ($param{page_number} == 1) {
188             return "header for first page\n";
189             }
190             return "== page %d of %d == \n";
191             }
192             sub process_template {
193             my %param = @_;
194             my $t = $param{template};
195             if ($t =~ /%/) {
196             return sprintf $t, $param{page_number}, $param{total_pages};
197             }
198             else {
199             return $t;
200             }
201             }
202             sub split_paragraph {
203             my %param = @_;
204             my ($first, $rest) = split /\n\n/, $param{paragraph}, 2;
205             return ("$first\n", $rest);
206             }
207              
208            
209             $layout = Text::PageLayout->new(
210             page_size => 42,
211             tolerance => 2,
212             paragraphs => [ ... ],
213             separator => "\n\n",
214             footer => "\nCopyright (C) 2014 by M. Lenz\n",
215             header => \&header,
216             process_template => \&process_template,
217             split_paragraph => \&split_paragraph,
218             );
219              
220             =head1 DESCRIPTION
221              
222             Text::PageLayout breaks up a list of paragraphs into pages. It supports
223             headers and footers, possibly varying by page number.
224              
225             It operates under the assumption that all text blocks that are passed to
226             (header, footer, paragraphs, separator) are either the empty string, or
227             terminated by a newline. It also assumes that all those text blocks are
228             properly line-wrapped already.
229              
230             The header and footer can either be strings, or subroutines that will be
231             called, and must return the string that is then used as a header or a footer.
232             In both cases, the string that is used as header or footer can be
233             post-processed with a custom callback C.
234              
235             The layout of a result page is always the header first, then as many
236             paragraphs as fit on the page, separated by C, followed by
237             as many blank lines as necessary to fill the page (if C is set,
238             which it is by default), followed by the footer.
239              
240             If the naive layouting algorithm (take as many paragraphs as fit) leaves
241             more than C empty fill lines, the C callback is
242             called, which can attempt to split the paragraph into small chunks, which are
243             nicer to format.
244              
245             =head1 ATTRIBUTES
246              
247             Attributes should be set in the constructor (C<< Text::PageLayout->new >>),
248             but most of them can also be set later on, for example the C
249             attribute with C<< $layout->page_size(42) >>.
250              
251             Note that all callbacks receive named arguments, i.e. are called like this:
252              
253             $callback->(
254             page_number => 1,
255             total_pages => 2,
256             );
257              
258             Callbacks I accept additional named arguments (future versions of this
259             module might pass more arguments).
260              
261             =head2 page_size
262              
263             Max. number of lines on a result page.
264              
265             Default: 67
266              
267             =head2 tolerance
268              
269             Number of empty lines to accept on a page before attempting to split
270             paragraphs.
271              
272             Default: 6 (subject to change)
273              
274             =head2 paragraphs
275              
276             An array reference of paragraphs (newline-terminated strings) that is to be
277             split up in pages.
278              
279             This attribute is required.
280              
281             =head2 header
282              
283             A string that is used as a per-page header (or header template, if C is
284             set), or a callback that returns the header.
285              
286             If used as a callback, it receives C as a named argument.
287              
288             Default: empty string.
289              
290             =head2 footer
291              
292             A string that is used as a per-page footer (or footer template, if C is
293             set), or a callback that returns the footer.
294              
295             If used as a callback, it receives C as a named argument.
296              
297             Default: empty string.
298              
299             =head2 fillup_pages
300              
301             If set to a true value, pages are filled up to their maximum length by
302             adding newlines before the footer.
303              
304             Default: 1.
305              
306             =head2 separator
307              
308             A string that is used to separate multiple paragraphs on the same page.
309              
310             Default: "\n" (newline)
311              
312             =head2 split_paragraph
313              
314             A callback that can split a paragraph into smaller chunks to create nicer
315             layouts. If paragraphs exist that exceed the number of free lines on a page
316             that only contains header or footer, this callback B split such a
317             paragraph into smaller chunks, the first of which must fit on a page.
318              
319             The return value must be a list of paragraphs.
320              
321             It receives the arguments C, C and C.
322             C is the maximal number of lines that fit on the current page.
323              
324             The default C simply splits off the first C as a
325             sparate chunk, without any consideration for its content.
326              
327             =head2 process_template
328              
329             A callback that turns a header or footer template into the actual header or
330             footer. It receives the named arguments C