| blib/lib/MsOffice/Word/HTML/Writer.pm | |||
|---|---|---|---|
| Criterion | Covered | Total | % |
| statement | 140 | 172 | 81.4 |
| branch | 22 | 52 | 42.3 |
| condition | 10 | 20 | 50.0 |
| subroutine | 23 | 26 | 88.4 |
| pod | 10 | 10 | 100.0 |
| total | 205 | 280 | 73.2 |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package MsOffice::Word::HTML::Writer; | ||||||
| 2 | 3 | 3 | 208439 | use utf8; | |||
| 3 | 56 | ||||||
| 3 | 15 | ||||||
| 3 | 3 | 3 | 92 | use warnings; | |||
| 3 | 6 | ||||||
| 3 | 68 | ||||||
| 4 | 3 | 3 | 16 | use strict; | |||
| 3 | 6 | ||||||
| 3 | 63 | ||||||
| 5 | 3 | 3 | 1493 | use MIME::Base64 qw/encode_base64/; | |||
| 3 | 2131 | ||||||
| 3 | 176 | ||||||
| 6 | 3 | 3 | 1399 | use MIME::Types; | |||
| 3 | 19931 | ||||||
| 3 | 134 | ||||||
| 7 | 3 | 3 | 21 | use Carp; | |||
| 3 | 6 | ||||||
| 3 | 154 | ||||||
| 8 | 3 | 3 | 1732 | use Params::Validate qw/validate SCALAR HASHREF/; | |||
| 3 | 28051 | ||||||
| 3 | 233 | ||||||
| 9 | 3 | 3 | 22 | use Scalar::Util qw/looks_like_number/; | |||
| 3 | 7 | ||||||
| 3 | 8651 | ||||||
| 10 | |||||||
| 11 | our $VERSION = '1.08'; | ||||||
| 12 | |||||||
| 13 | sub new { | ||||||
| 14 | 2 | 2 | 1 | 200 | my $class = shift; | ||
| 15 | |||||||
| 16 | # validate named parameters | ||||||
| 17 | 2 | 34 | my $param_spec = { | ||||
| 18 | title => {type => SCALAR, optional => 1}, | ||||||
| 19 | head => {type => SCALAR, optional => 1}, | ||||||
| 20 | hf_head => {type => SCALAR, optional => 1}, | ||||||
| 21 | WordDocument => {type => HASHREF, optional => 1}, | ||||||
| 22 | charset => {type => SCALAR, optional => 1, default => 'utf-8'}, | ||||||
| 23 | }; | ||||||
| 24 | 2 | 68 | my %params = validate(@_, $param_spec); | ||||
| 25 | |||||||
| 26 | # create instance | ||||||
| 27 | my $self = { | ||||||
| 28 | MIME_parts => [], | ||||||
| 29 | sections => [{}], | ||||||
| 30 | title => $params{title} | ||||||
| 31 | || "Document generated by MsOffice::Word::HTML::Writer", | ||||||
| 32 | head => $params{head} || "", | ||||||
| 33 | hf_head => $params{hf_head} || "", | ||||||
| 34 | WordDocument => $params{WordDocument}, | ||||||
| 35 | charset => $params{charset}, | ||||||
| 36 | 2 | 50 | 45 | }; | |||
| 50 | |||||||
| 50 | |||||||
| 37 | |||||||
| 38 | 2 | 15 | bless $self, $class; | ||||
| 39 | } | ||||||
| 40 | |||||||
| 41 | |||||||
| 42 | sub create_section { | ||||||
| 43 | 4 | 4 | 1 | 27 | my $self = shift; | ||
| 44 | |||||||
| 45 | # validate named parameters | ||||||
| 46 | 4 | 11 | my $param_spec = {page => {type => HASHREF, optional => 1}}; | ||||
| 47 | $param_spec->{$_} = {type => SCALAR, optional => 1} | ||||||
| 48 | 4 | 65 | for qw/header footer first_header first_footer new_page/; | ||||
| 49 | 4 | 107 | my %params = validate(@_, $param_spec); | ||||
| 50 | |||||||
| 51 | # if first automatic section is empty, delete it | ||||||
| 52 | $self->{sections} = [] | ||||||
| 53 | 4 | 50 | 66 | 19 | if scalar(@{$self->{sections}}) == 1 && !$self->{sections}[0]{content}; | ||
| 4 | 23 | ||||||
| 54 | |||||||
| 55 | # add the new section | ||||||
| 56 | 4 | 6 | push @{$self->{sections}}, \%params; | ||||
| 4 | 25 | ||||||
| 57 | } | ||||||
| 58 | |||||||
| 59 | |||||||
| 60 | sub write { | ||||||
| 61 | 14 | 14 | 1 | 53 | my $self = shift; | ||
| 62 | |||||||
| 63 | # add html arguments to current section content | ||||||
| 64 | 14 | 67 | $self->{sections}[-1]{content} .= join ("", @_); | ||||
| 65 | } | ||||||
| 66 | |||||||
| 67 | |||||||
| 68 | |||||||
| 69 | sub save_as { | ||||||
| 70 | 0 | 0 | 1 | 0 | my ($self, $filename) = @_; | ||
| 71 | |||||||
| 72 | # default extension is ".doc" | ||||||
| 73 | 0 | 0 | 0 | $filename .= ".doc" unless $filename =~ /\.\w{1,5}$/; | |||
| 74 | |||||||
| 75 | # open the file | ||||||
| 76 | 0 | 0 | 0 | open my $fh, ">:crlf", $filename | |||
| 77 | or croak "could not open >$filename: $!"; | ||||||
| 78 | |||||||
| 79 | # write content and close | ||||||
| 80 | 0 | 0 | print $fh $self->content; | ||||
| 81 | 0 | 0 | close $fh; | ||||
| 82 | |||||||
| 83 | 0 | 0 | return $filename; | ||||
| 84 | } | ||||||
| 85 | |||||||
| 86 | |||||||
| 87 | sub attach { | ||||||
| 88 | 0 | 0 | 1 | 0 | my ($self, $name, $open1, $open2, @other) = @_; | ||
| 89 | |||||||
| 90 | # open a handle to the attachment (need to dispatch according to number | ||||||
| 91 | # of args, because perlfunc/open() has complex prototyping behaviour) | ||||||
| 92 | 0 | 0 | my $fh; | ||||
| 93 | 0 | 0 | 0 | if (@other) { | |||
| 0 | |||||||
| 94 | 0 | 0 | 0 | open $fh, $open1, $open2, @other | |||
| 95 | or croak "open $open1, $open2, @other : $!"; | ||||||
| 96 | } | ||||||
| 97 | elsif ($open2) { | ||||||
| 98 | 0 | 0 | 0 | open $fh, $open1, $open2 | |||
| 99 | or croak "open $open1, $open2 : $!"; | ||||||
| 100 | } | ||||||
| 101 | else { | ||||||
| 102 | 0 | 0 | 0 | open $fh, $open1 | |||
| 103 | or croak "open $open1 : $!"; | ||||||
| 104 | } | ||||||
| 105 | |||||||
| 106 | # slurp the content | ||||||
| 107 | 0 | 0 | 0 | binmode($fh) unless $name =~ /\.(html?|css|te?xt|rtf)$/i; | |||
| 108 | 0 | 0 | local $/; | ||||
| 109 | 0 | 0 | my $attachment = <$fh>; | ||||
| 110 | |||||||
| 111 | # add the attachment (filename and content) | ||||||
| 112 | 0 | 0 | push @{$self->{MIME_parts}}, ["files/$name", $attachment]; | ||||
| 0 | 0 | ||||||
| 113 | } | ||||||
| 114 | |||||||
| 115 | |||||||
| 116 | sub page_break { | ||||||
| 117 | 2 | 2 | 1 | 11 | my ($self) = @_; | ||
| 118 | 2 | 7 | return qq{ \n}; |
||||
| 119 | } | ||||||
| 120 | |||||||
| 121 | |||||||
| 122 | sub tab { | ||||||
| 123 | 0 | 0 | 1 | 0 | my ($self, $n_tabs) = @_; | ||
| 124 | 0 | 0 | 0 | $n_tabs ||= 1; | |||
| 125 | 0 | 0 | return qq{}; | ||||
| 126 | } | ||||||
| 127 | |||||||
| 128 | sub field { | ||||||
| 129 | 2 | 2 | 1 | 7 | my ($self, $fieldname, $args, $content, $prevent_html_entity_encoding) = @_; | ||
| 130 | |||||||
| 131 | 2 | 7 | for ($args, $content) { | ||||
| 132 | 4 | 50 | 11 | $_ ||= ""; # undef replaced by empty string | |||
| 133 | 4 | 50 | 13 | s/&/&/g, s/</g, s/>/>/g # replace HTML entities | |||
| 134 | unless $prevent_html_entity_encoding; | ||||||
| 135 | } | ||||||
| 136 | |||||||
| 137 | 2 | 5 | my $field; | ||||
| 138 | |||||||
| 139 | # when args : long form of field encoding | ||||||
| 140 | 2 | 50 | 7 | if ($args) { | |||
| 141 | 2 | 5 | my $space = qq{ }; | ||||
| 142 | 2 | 14 | $field = qq{} | ||||
| 143 | . $space . $fieldname . $space . $args | ||||||
| 144 | . qq{} | ||||||
| 145 | . $content | ||||||
| 146 | . qq{}; | ||||||
| 147 | } | ||||||
| 148 | # otherwise : short form of field encoding | ||||||
| 149 | else { | ||||||
| 150 | 0 | 0 | $field = qq{$content}; | ||||
| 151 | } | ||||||
| 152 | |||||||
| 153 | 2 | 8 | return $field; | ||||
| 154 | } | ||||||
| 155 | |||||||
| 156 | sub quote { | ||||||
| 157 | 2 | 2 | 1 | 16 | my ($self, $text, $prevent_html_entity_encoding) = @_; | ||
| 158 | 2 | 5 | my $args = $text; | ||||
| 159 | 2 | 7 | $args =~ s/"/\\"/g; | ||||
| 160 | 2 | 8 | $args = qq{"$args"}; | ||||
| 161 | 2 | 9 | $args =~ s/"/"/g; | ||||
| 162 | 2 | 6 | return $self->field('QUOTE', $args, $text, $prevent_html_entity_encoding); | ||||
| 163 | } | ||||||
| 164 | |||||||
| 165 | |||||||
| 166 | |||||||
| 167 | sub content { | ||||||
| 168 | 2 | 2 | 1 | 6 | my ($self) = @_; | ||
| 169 | |||||||
| 170 | # separator for parts in MIME document | ||||||
| 171 | 2 | 5 | my $boundary = qw/__NEXT_PART__/; | ||||
| 172 | |||||||
| 173 | # MIME multipart header | ||||||
| 174 | 2 | 19 | my $mime = qq{MIME-Version: 1.0\n} | ||||
| 175 | . qq{Content-Type: multipart/related; boundary="$boundary"\n\n} | ||||||
| 176 | . qq{MIME document generated by MsOffice::Word::HTML::Writer\n\n}; | ||||||
| 177 | |||||||
| 178 | # generate each part (main document must be first) | ||||||
| 179 | 2 | 7 | my @parts = $self->_MIME_parts; | ||||
| 180 | 2 | 15 | my $filelist = $self->_filelist(@parts); | ||||
| 181 | 2 | 12 | for my $pair ($self->_main, @parts, $filelist) { | ||||
| 182 | 4 | 18 | my ($filename, $content) = @$pair; | ||||
| 183 | 4 | 50 | 30 | my $mime_type = MIME::Types->new->mimeTypeOf($filename) || ''; | |||
| 184 | 4 | 130826 | my ($encoding, $encoded); | ||||
| 185 | 4 | 50 | 14 | if ($mime_type =~ /^text|xml$/) { | |||
| 186 | # no need for Windows-style end-of-lines of shape CRLF | ||||||
| 187 | 4 | 64 | $content =~ s/\r\n/\n/g; | ||||
| 188 | |||||||
| 189 | # if charset is not utf-8, wide chars are encoded as numerical HTML entities | ||||||
| 190 | 4 | 100 | 54 | $content =~ s/([^\x{0}-\x{FF}])/''.ord($1).';'/eg unless $self->{charset} eq 'utf-8'; | |||
| 8 | 87 | ||||||
| 191 | |||||||
| 192 | # simple-minded MIME quoted-printable encoding | ||||||
| 193 | 4 | 11 | $encoding = 'quoted-printable'; | ||||
| 194 | 4 | 53 | ($encoded = $content) =~ s/=/=3D/g; | ||||
| 195 | 4 | 18 | $mime_type .= "; charset=$self->{charset}"; | ||||
| 196 | } | ||||||
| 197 | else { | ||||||
| 198 | 0 | 0 | $encoding = 'base64'; | ||||
| 199 | 0 | 0 | $encoded = encode_base64($content); | ||||
| 200 | } | ||||||
| 201 | |||||||
| 202 | 4 | 81 | $mime .= qq{--$boundary\n} | ||||
| 203 | . qq{Content-Location: file:///C:/foo/$filename\n} | ||||||
| 204 | . qq{Content-Transfer-Encoding: $encoding\n} | ||||||
| 205 | . qq{Content-Type: $mime_type\n\n} | ||||||
| 206 | . $encoded | ||||||
| 207 | . "\n"; | ||||||
| 208 | } | ||||||
| 209 | |||||||
| 210 | # close last MIME part | ||||||
| 211 | 2 | 12 | $mime .= "--$boundary--\n"; | ||||
| 212 | |||||||
| 213 | 2 | 26 | return $mime; | ||||
| 214 | } | ||||||
| 215 | |||||||
| 216 | |||||||
| 217 | #====================================================================== | ||||||
| 218 | # PRIVATE METHODS | ||||||
| 219 | #====================================================================== | ||||||
| 220 | |||||||
| 221 | sub _main { | ||||||
| 222 | 2 | 2 | 11 | my ($self) = @_; | |||
| 223 | |||||||
| 224 | # body : concatenate content from all sections | ||||||
| 225 | 2 | 6 | my $body = ""; | ||||
| 226 | 2 | 4 | my $i = 1; | ||||
| 227 | 2 | 5 | foreach my $section (@{$self->{sections}}) { | ||||
| 2 | 6 | ||||||
| 228 | |||||||
| 229 | # section break | ||||||
| 230 | 6 | 100 | 23 | if ($i > 1) { | |||
| 231 | # type of break | ||||||
| 232 | 4 | 8 | my $break = $section->{new_page}; | ||||
| 233 | 4 | 100 | 66 | 38 | $break = 'always' if $break && looks_like_number($break); # if true but not a word | ||
| 234 | 4 | 50 | 11 | $break ||= 'auto'; # if false | |||
| 235 | # otherwise, type of break will just be the word given in {new_page} | ||||||
| 236 | |||||||
| 237 | # insert into body | ||||||
| 238 | 4 | 13 | my $style = qq{page-break-before:$break;mso-break-type:section-break}; | ||||
| 239 | 4 | 9 | $body .= qq{ \n}; |
||||
| 240 | } | ||||||
| 241 | |||||||
| 242 | # section content | ||||||
| 243 | 6 | 34 | $body .= qq{ \n$section->{content}\n \n};
|
||||
| 244 | |||||||
| 245 | 6 | 13 | $i += 1; | ||||
| 246 | } | ||||||
| 247 | |||||||
| 248 | # assemble head and body into a full document | ||||||
| 249 | 2 | 8 | my $html | ||||
| 250 | = qq{ | ||||||
| 251 | . qq{ xmlns:o="urn:schemas-microsoft-com:office:office"\n} | ||||||
| 252 | . qq{ xmlns:w="urn:schemas-microsoft-com:office:word"\n} | ||||||
| 253 | . qq{ xmlns:m="http://schemas.microsoft.com/office/2004/12/omml"\n} | ||||||
| 254 | . qq{ xmlns="http://www.w3.org/TR/REC-html40">\n} | ||||||
| 255 | . $self->_head | ||||||
| 256 | . qq{\n$body\n} | ||||||
| 257 | . qq{\n}; | ||||||
| 258 | 2 | 13 | return ["main.htm", $html]; | ||||
| 259 | } | ||||||
| 260 | |||||||
| 261 | |||||||
| 262 | sub _head { | ||||||
| 263 | 2 | 2 | 8 | my ($self) = @_; | |||
| 264 | |||||||
| 265 | # HTML head : link to filelist, title, view format and styles | ||||||
| 266 | my $head | ||||||
| 267 | = qq{\n} | ||||||
| 268 | . qq{\n} | ||||||
| 269 | . qq{ |
||||||
| 270 | . $self->_xml_WordDocument | ||||||
| 271 | . qq{\n} | ||||||
| 272 | . $self->{head} | ||||||
| 273 | 2 | 10 | . qq{\n}; | ||||
| 274 | 2 | 30 | return $head; | ||||
| 275 | } | ||||||
| 276 | |||||||
| 277 | |||||||
| 278 | |||||||
| 279 | sub _xml_WordDocument { | ||||||
| 280 | 2 | 2 | 4 | my ($self) = @_; | |||
| 281 | 2 | 50 | 9 | my $xml_root = $self->{WordDocument} or return ""; | |||
| 282 | 2 | 6 | return " |
||||
| 283 | . _w_xml($xml_root) | ||||||
| 284 | . "\n"; | ||||||
| 285 | } | ||||||
| 286 | |||||||
| 287 | |||||||
| 288 | sub _w_xml { | ||||||
| 289 | 4 | 4 | 14 | my $node = shift; | |||
| 290 | 4 | 9 | my $xml = ""; | ||||
| 291 | 4 | 20 | while (my ($k, $v) = each %$node) { | ||||
| 292 | 6 | 100 | 41 | $xml .= $v ? ( # node with content | |||
| 100 | |||||||
| 293 | " |
||||||
| 294 | . (ref $v ? _w_xml($v) : $v) | ||||||
| 295 | . "\n" ) | ||||||
| 296 | : " |
||||||
| 297 | } | ||||||
| 298 | 4 | 25 | return $xml; | ||||
| 299 | } | ||||||
| 300 | |||||||
| 301 | |||||||
| 302 | sub _section_styles { | ||||||
| 303 | 2 | 2 | 7 | my ($self) = @_; | |||
| 304 | |||||||
| 305 | 2 | 4 | my $styles = ""; | ||||
| 306 | 2 | 4 | my $i = 1; | ||||
| 307 | 2 | 7 | foreach my $section (@{$self->{sections}}) { | ||||
| 2 | 8 | ||||||
| 308 | |||||||
| 309 | 6 | 12 | my $properties = ""; | ||||
| 310 | |||||||
| 311 | # page properties (size and margin) | ||||||
| 312 | 6 | 9 | foreach my $prop (qw/size margin/) { | ||||
| 313 | 12 | 50 | 42 | my $val = $section->{page}{$prop} or next; | |||
| 314 | 0 | 0 | $properties .= qq{ $prop:$val;\n}; | ||||
| 315 | } | ||||||
| 316 | |||||||
| 317 | # headers and footers | ||||||
| 318 | 6 | 9 | my $has_first_page; | ||||
| 319 | 6 | 14 | foreach my $prop (qw/header_margin footer_margin | ||||
| 320 | page_numbers paper_source/) { | ||||||
| 321 | 24 | 50 | 60 | my $val = $section->{page}{$prop} or next; | |||
| 322 | 0 | 0 | (my $property = $prop) =~ s/_/-/g; | ||||
| 323 | 0 | 0 | $properties .= qq{ mso-$property:$val;\n}; | ||||
| 324 | } | ||||||
| 325 | 6 | 20 | foreach my $hf (qw/header footer first_header first_footer/) { | ||||
| 326 | 24 | 50 | 74 | $section->{$hf} or next; | |||
| 327 | 0 | 0 | 0 | $has_first_page = 1 if $hf =~ /^first/; | |||
| 328 | 0 | 0 | (my $property = $hf) =~ s/_/-/; | ||||
| 329 | 0 | 0 | $properties | ||||
| 330 | .= qq{ mso-$property:url("files/header_footer.htm") $hf$i;\n}; | ||||||
| 331 | } | ||||||
| 332 | 6 | 50 | 15 | $properties .= qq{ mso-title-page:yes;\n} if $has_first_page; | |||
| 333 | |||||||
| 334 | # style definitions for this section | ||||||
| 335 | 6 | 23 | $styles .= qq[\@page Section$i {\n$properties}\n] | ||||
| 336 | . qq[div.Section$i {page:Section$i}\n]; | ||||||
| 337 | 6 | 12 | $i += 1; | ||||
| 338 | } | ||||||
| 339 | |||||||
| 340 | 2 | 27 | return $styles; | ||||
| 341 | } | ||||||
| 342 | |||||||
| 343 | |||||||
| 344 | sub _MIME_parts { | ||||||
| 345 | 2 | 2 | 4 | my ($self) = @_; | |||
| 346 | |||||||
| 347 | # attachments supplied by user | ||||||
| 348 | 2 | 4 | my @parts = @{$self->{MIME_parts}}; | ||||
| 2 | 6 | ||||||
| 349 | |||||||
| 350 | # additional attachment : computed file with headers and footers | ||||||
| 351 | 2 | 6 | my $hf_content = $self->_header_footer; | ||||
| 352 | 2 | 50 | 6 | unshift @parts, ["files/header_footer.htm", $hf_content] if $hf_content; | |||
| 353 | |||||||
| 354 | 2 | 6 | return @parts; | ||||
| 355 | } | ||||||
| 356 | |||||||
| 357 | |||||||
| 358 | sub _header_footer { | ||||||
| 359 | 2 | 2 | 8 | my ($self) = @_; | |||
| 360 | |||||||
| 361 | # create a div for each header/footer in each section | ||||||
| 362 | 2 | 6 | my $hf_divs = ""; | ||||
| 363 | 2 | 3 | my $i = 1; | ||||
| 364 | 2 | 5 | foreach my $section (@{$self->{sections}}) { | ||||
| 2 | 7 | ||||||
| 365 | |||||||
| 366 | # deal with headers/footers defined in that section | ||||||
| 367 | 6 | 11 | foreach my $hf (qw/header footer first_header first_footer/) { | ||||
| 368 | 24 | 50 | 50 | $section->{$hf} or next; | |||
| 369 | 0 | 0 | (my $style = $hf) =~ s/^first_//; | ||||
| 370 | $hf_divs .= qq{ \n}
|
||||||
| 371 | 0 | 0 | . $section->{$hf} . "\n" | ||||
| 372 | . qq{\n}; | ||||||
| 373 | } | ||||||
| 374 | |||||||
| 375 | 6 | 13 | $i += 1; | ||||
| 376 | } | ||||||
| 377 | |||||||
| 378 | # if at least one such div, need to create an attached file | ||||||
| 379 | my $header_footer = !$hf_divs ? "" : | ||||||
| 380 | qq{\n} | ||||||
| 381 | . qq{\n} | ||||||
| 382 | . qq{\n} | ||||||
| 383 | . $self->{hf_head} | ||||||
| 384 | 2 | 50 | 18 | . qq{\n} | |||
| 385 | . qq{\n} . $hf_divs . qq{\n} | ||||||
| 386 | . qq{\n}; | ||||||
| 387 | |||||||
| 388 | 2 | 7 | return $header_footer; | ||||
| 389 | } | ||||||
| 390 | |||||||
| 391 | |||||||
| 392 | |||||||
| 393 | sub _filelist { | ||||||
| 394 | 2 | 2 | 8 | my ($self, @parts) = @_; | |||
| 395 | |||||||
| 396 | # xml header | ||||||
| 397 | 2 | 5 | my $xml = qq{ |
||||
| 398 | . qq{ |
||||||
| 399 | |||||||
| 400 | # refer to each attached file | ||||||
| 401 | 2 | 5 | foreach my $part (@parts) { | ||||
| 402 | 0 | 0 | $xml .= qq{ |
||||
| 403 | } | ||||||
| 404 | |||||||
| 405 | # the filelist is itself an attached file | ||||||
| 406 | 2 | 13 | $xml .= qq{ |
||||
| 407 | |||||||
| 408 | # closing tag; | ||||||
| 409 | 2 | 7 | $xml .= qq{\n}; | ||||
| 410 | |||||||
| 411 | 2 | 14 | return ["files/filelist.xml", $xml]; | ||||
| 412 | } | ||||||
| 413 | |||||||
| 414 | |||||||
| 415 | |||||||
| 416 | 1; | ||||||
| 417 | |||||||
| 418 | __END__ |