File Coverage

blib/lib/DOM/Tiny/_HTML.pm
Criterion Covered Total %
statement 98 99 98.9
branch 90 92 97.8
condition 34 43 79.0
subroutine 14 14 100.0
pod 0 5 0.0
total 236 253 93.2


line stmt bran cond sub pod time code
1             package DOM::Tiny::_HTML;
2              
3 1     1   4 use strict;
  1         0  
  1         23  
4 1     1   3 use warnings;
  1         1  
  1         21  
5 1     1   393 use DOM::Tiny::Entities qw(html_escape html_unescape);
  1         2  
  1         132  
6 1     1   9 use Scalar::Util 'weaken';
  1         0  
  1         1087  
7              
8             our $VERSION = '0.003';
9              
10             my $ATTR_RE = qr/
11             ([^<>=\s\/]+|\/) # Key
12             (?:
13             \s*=\s*
14             (?s:(?:"(.*?)")|(?:'(.*?)')|([^>\s]*)) # Value
15             )?
16             \s*
17             /x;
18             my $TOKEN_RE = qr/
19             ([^<]+)? # Text
20             (?:
21             <(?:
22             !(?:
23             DOCTYPE(
24             \s+\w+ # Doctype
25             (?:(?:\s+\w+)?(?:\s+(?:"[^"]*"|'[^']*'))+)? # External ID
26             (?:\s+\[.+?\])? # Int Subset
27             \s*)
28             |
29             --(.*?)--\s* # Comment
30             |
31             \[CDATA\[(.*?)\]\] # CDATA
32             )
33             |
34             \?(.*?)\? # Processing Instruction
35             |
36             \s*([^<>\s]+\s*(?>(?:$ATTR_RE){0,32766})*) # Tag
37             # Workaround for perl's limit of * to {0,32767}
38             )>
39             |
40             (<) # Runaway "<"
41             )??
42             /xis;
43              
44             # HTML elements that only contain raw text
45             my %RAW = map { $_ => 1 } qw(script style);
46              
47             # HTML elements that only contain raw text and entities
48             my %RCDATA = map { $_ => 1 } qw(title textarea);
49              
50             # HTML elements with optional end tags
51             my %END = (body => 'head', optgroup => 'optgroup', option => 'option');
52              
53             # HTML elements that break paragraphs
54             $END{$_} = 'p' for
55             qw(address article aside blockquote dir div dl fieldset footer form h1 h2),
56             qw(h3 h4 h5 h6 header hr main menu nav ol p pre section table ul);
57              
58             # HTML table elements with optional end tags
59             my %TABLE = map { $_ => 1 } qw(colgroup tbody td tfoot th thead tr);
60              
61             # HTML elements with optional end tags and scoping rules
62             my %CLOSE
63             = (li => [{li => 1}, {ul => 1, ol => 1}], tr => [{tr => 1}, {table => 1}]);
64             $CLOSE{$_} = [\%TABLE, {table => 1}] for qw(colgroup tbody tfoot thead);
65             $CLOSE{$_} = [{dd => 1, dt => 1}, {dl => 1}] for qw(dd dt);
66             $CLOSE{$_} = [{rp => 1, rt => 1}, {ruby => 1}] for qw(rp rt);
67             $CLOSE{$_} = [{th => 1, td => 1}, {table => 1}] for qw(td th);
68              
69             # HTML elements without end tags
70             my %EMPTY = map { $_ => 1 } (
71             qw(area base br col embed hr img input keygen link menuitem meta param),
72             qw(source track wbr)
73             );
74              
75             # HTML elements categorized as phrasing content (and obsolete inline elements)
76             my @PHRASING = (
77             qw(a abbr area audio b bdi bdo br button canvas cite code data datalist),
78             qw(del dfn em embed i iframe img input ins kbd keygen label link map mark),
79             qw(math meta meter noscript object output picture progress q ruby s samp),
80             qw(script select small span strong sub sup svg template textarea time u),
81             qw(var video wbr)
82             );
83             my @OBSOLETE = qw(acronym applet basefont big font strike tt);
84             my %PHRASING = map { $_ => 1 } @OBSOLETE, @PHRASING;
85              
86             # HTML elements that don't get their self-closing flag acknowledged
87             my %BLOCK = map { $_ => 1 } (
88             qw(a address applet article aside b big blockquote body button caption),
89             qw(center code col colgroup dd details dialog dir div dl dt em fieldset),
90             qw(figcaption figure font footer form frameset h1 h2 h3 h4 h5 h6 head),
91             qw(header hgroup html i iframe li listing main marquee menu nav nobr),
92             qw(noembed noframes noscript object ol optgroup option p plaintext pre rp),
93             qw(rt s script section select small strike strong style summary table),
94             qw(tbody td template textarea tfoot th thead title tr tt u ul xmp)
95             );
96              
97             sub new {
98 1952     1952 0 1795 my $class = shift;
99 1952 50 33     13153 bless @_ ? @_ > 1 ? {@_} : {%{$_[0]}} : {}, ref $class || $class;
  0 100       0  
100             }
101              
102             sub tree {
103 6079     6079 0 5626 my $self = shift;
104 6079 100       22667 return exists $self->{tree} ? $self->{tree} : ($self->{tree} = ['root']) unless @_;
    100          
105 1965         3132 $self->{tree} = shift;
106 1965         3167 return $self;
107             }
108              
109             sub xml {
110 3236     3236 0 2638 my $self = shift;
111 3236 100       7452 return $self->{xml} unless @_;
112 1775         1969 $self->{xml} = shift;
113 1775         2078 return $self;
114             }
115              
116             sub parse {
117 194     194 0 545 my ($self, $html) = (shift, "$_[0]");
118              
119 194         397 my $xml = $self->xml;
120 194         399 my $current = my $tree = ['root'];
121 194         1250 while ($html =~ /\G$TOKEN_RE/gcso) {
122 3697         9163 my ($text, $doctype, $comment, $cdata, $pi, $tag, $runaway)
123             = ($1, $2, $3, $4, $5, $6, $11);
124              
125             # Text (and runaway "<")
126 3697 100       5021 $text .= '<' if defined $runaway;
127 3697 100       6320 _node($current, 'text', html_unescape $text) if defined $text;
128              
129             # Tag
130 3697 100       88961 if (defined $tag) {
    100          
    100          
    100          
    100          
131              
132             # End
133 1243 100       4873 if ($tag =~ /^\/\s*(\S+)/) { _end($xml ? $1 : lc $1, $xml, \$current) }
  513 100       1511  
    50          
134              
135             # Start
136             elsif ($tag =~ m!^([^\s/]+)([\s\S]*)!) {
137 730 100       1995 my ($start, $attr) = ($xml ? $1 : lc $1, $2);
138              
139             # Attributes
140 730         661 my (%attrs, $closing);
141 730         1967 while ($attr =~ /$ATTR_RE/go) {
142 33051 100       48534 my $key = $xml ? $1 : lc $1;
143 33051 100       57390 my $value = defined($2) ? $2 : defined($3) ? $3 : $4;
    100          
144              
145             # Empty tag
146 33051 100 50     40793 ++$closing and next if $key eq '/';
147              
148 33012 100       57027 $attrs{$key} = defined $value ? html_unescape $value : $value;
149             }
150              
151             # "image" is an alias for "img"
152 730 100 100     2500 $start = 'img' if !$xml && $start eq 'image';
153 730         1435 _start($start, \%attrs, $xml, \$current);
154              
155             # Element without end tag (self-closing)
156             _end($start, $xml, \$current)
157 730 100 66     4383 if !$xml && $EMPTY{$start} || ($xml || !$BLOCK{$start}) && $closing;
      100        
      100        
      66        
158              
159             # Raw text elements
160 730 100 100     5672 next if $xml || !$RAW{$start} && !$RCDATA{$start};
      100        
161 28 100       529 next unless $html =~ m!\G(.*?)<\s*/\s*\Q$start\E\s*>!gcsi;
162 27 100       126 _node($current, 'raw', $RCDATA{$start} ? html_unescape $1 : $1);
163 27         72 _end($start, 0, \$current);
164             }
165             }
166              
167             # DOCTYPE
168 11         37 elsif (defined $doctype) { _node($current, 'doctype', $doctype) }
169              
170             # Comment
171 10         18 elsif (defined $comment) { _node($current, 'comment', $comment) }
172              
173             # CDATA
174 7         63 elsif (defined $cdata) { _node($current, 'cdata', $cdata) }
175              
176             # Processing instruction (try to detect XML)
177             elsif (defined $pi) {
178 17 100 100     163 $self->xml($xml = 1) if !exists $self->{xml} && $pi =~ /xml/i;
179 17         42 _node($current, 'pi', $pi);
180             }
181             }
182              
183 194         415 return $self->tree($tree);
184             }
185              
186 125     125 0 247 sub render { _render($_[0]->tree, $_[0]->xml) }
187              
188             sub _end {
189 768     768   923 my ($end, $xml, $current) = @_;
190              
191             # Search stack for start tag
192 768         659 my $next = $$current;
193 768         618 do {
194              
195             # Ignore useless end tag
196 1046 100       1908 return if $next->[0] eq 'root';
197              
198             # Right tag
199 933 100       3644 return $$current = $next->[3] if $next->[1] eq $end;
200              
201             # Phrasing content can only cross phrasing content
202 283 100 66     1196 return if !$xml && $PHRASING{$end} && !$PHRASING{$next->[1]};
      66        
203              
204             } while $next = $next->[3];
205             }
206              
207             sub _node {
208 992     992   1145 my ($current, $type, $content) = @_;
209 992         2341 push @$current, my $new = [$type, $content, $current];
210 992         2235 weaken $new->[2];
211             }
212              
213             sub _render {
214 852     852   747 my ($tree, $xml) = @_;
215              
216             # Text (escaped)
217 852         783 my $type = $tree->[0];
218 852 100       1621 return html_escape($tree->[1]) if $type eq 'text';
219              
220             # Raw text
221 477 100       638 return $tree->[1] if $type eq 'raw';
222              
223             # DOCTYPE
224 473 100       680 return '[1] . '>' if $type eq 'doctype';
225              
226             # Comment
227 469 100       580 return '' if $type eq 'comment';
228              
229             # CDATA
230 465 100       609 return '[1] . ']]>' if $type eq 'cdata';
231              
232             # Processing instruction
233 462 100       599 return '[1] . '?>' if $type eq 'pi';
234              
235             # Root
236 459 100       891 return join '', map { _render($_, $xml) } @$tree[1 .. $#$tree]
  143         231  
237             if $type eq 'root';
238              
239             # Start tag
240 362         394 my $tag = $tree->[1];
241 362         452 my $result = "<$tag";
242              
243             # Attributes
244 362         295 for my $key (sort keys %{$tree->[2]}) {
  362         964  
245 54         72 my $value = $tree->[2]{$key};
246 54 100 50     117 $result .= $xml ? qq{ $key="$key"} : " $key" and next unless defined $value;
    100          
247 47         138 $result .= qq{ $key="} . html_escape($value) . '"';
248             }
249              
250             # No children
251 362 100       795 return $xml ? "$result />" : $EMPTY{$tag} ? "$result>" : "$result>"
    100          
    100          
252             unless $tree->[4];
253              
254             # Children
255 1     1   5 no warnings 'recursion';
  1         1  
  1         246  
256 332         509 $result .= '>' . join '', map { _render($_, $xml) } @$tree[4 .. $#$tree];
  584         1034  
257              
258             # End tag
259 332         1518 return "$result";
260             }
261              
262             sub _start {
263 730     730   898 my ($start, $attrs, $xml, $current) = @_;
264              
265             # Autoclose optional HTML elements
266 730 100 100     2534 if (!$xml && $$current->[0] ne 'root') {
267 478 100       1323 if (my $end = $END{$start}) { _end($end, 0, $current) }
  118 100       173  
268              
269             elsif (my $close = $CLOSE{$start}) {
270 123         180 my ($allowed, $scope) = @$close;
271              
272             # Close allowed parent elements in scope
273 123         100 my $parent = $$current;
274 123   66     503 while ($parent->[0] ne 'root' && !$scope->{$parent->[1]}) {
275 119 100       227 _end($parent->[1], 0, $current) if $allowed->{$parent->[1]};
276 119         417 $parent = $parent->[3];
277             }
278             }
279             }
280              
281             # New tag
282 730         2022 push @$$current, my $new = ['tag', $start, $attrs, $$current];
283 730         1651 weaken $new->[3];
284 730         1039 $$current = $new;
285             }
286              
287             1;
288              
289             =for Pod::Coverage *EVERYTHING*
290              
291             =cut