1 <!-- $Id: diff.php,v 1.6 2001-02-10 22:15:08 dairiki Exp $ -->
5 // A PHP diff engine for phpwiki.
7 // Copyright (C) 2000 Geoffrey T. Dairiki <dairiki@dairiki.org>
8 // You may copy this code freely under the conditions of the GPL.
11 // FIXME: possibly remove assert()'s for production version?
13 // PHP3 does not have assert()
14 define('USE_ASSERTS', function_exists('assert'));
18 * Class used internally by WikiDiff to actually compute the diffs.
20 * The algorithm used here is mostly lifted from the perl module
21 * Algorithm::Diff (version 1.06) by Ned Konz, which is available at:
22 * http://www.perl.com/CPAN/authors/id/N/NE/NEDKONZ/Algorithm-Diff-1.06.zip
24 * More ideas are taken from:
25 * http://www.ics.uci.edu/~eppstein/161/960229.html
27 * Some ideas are (and a bit of code) are from from analyze.c, from GNU
28 * diffutils-2.7, which can be found at:
29 * ftp://gnudist.gnu.org/pub/gnu/diffutils/diffutils-2.7.tar.gz
31 * Finally, some ideas (subdivision by NCHUNKS > 2, and some optimizations)
36 var $edits; // List of editing operation to convert XV to YV.
37 var $xv = array(), $yv = array();
39 function _WikiDiffEngine ($from_lines, $to_lines)
41 $n_from = sizeof($from_lines);
42 $n_to = sizeof($to_lines);
45 // Ignore trailing and leading matching lines.
46 while ($n_from > 0 && $n_to > 0)
48 if ($from_lines[$n_from - 1] != $to_lines[$n_to - 1])
54 for ( $skip = 0; $skip < min($n_from, $n_to); $skip++)
55 if ($from_lines[$skip] != $to_lines[$skip])
63 // Ignore lines which do not exist in both files.
64 for ($x = 0; $x < $n_from; $x++)
65 $xhash[$from_lines[$x + $skip]] = 1;
66 for ($y = 0; $y < $n_to; $y++)
68 $line = $to_lines[$y + $skip];
70 if ( ($this->ychanged[$y] = empty($xhash[$line])) )
76 for ($x = 0; $x < $n_from; $x++)
78 $line = $from_lines[$x + $skip];
80 if ( ($this->xchanged[$x] = empty($yhash[$line])) )
87 $this->_compareseq(0, sizeof($this->xv), 0, sizeof($this->yv));
89 // Merge edits when possible
90 $this->_shift_boundaries($xlines, $this->xchanged, $this->ychanged);
91 $this->_shift_boundaries($ylines, $this->ychanged, $this->xchanged);
93 // Compute the edit operations.
94 $this->edits = array();
96 $this->edits[] = $skip;
100 while ($x < $n_from || $y < $n_to)
102 USE_ASSERTS && assert($y < $n_to || $this->xchanged[$x]);
103 USE_ASSERTS && assert($x < $n_from || $this->ychanged[$y]);
105 // Skip matching "snake".
109 while ( $x < $n_from && $y < $n_to
110 && !$this->xchanged[$x] && !$this->ychanged[$y])
117 $this->edits[] = $x - $x0;
122 while ($x < $n_from && $this->xchanged[$x])
128 $this->edits[] = -($x - $x0);
131 if ($this->ychanged[$y])
134 while ($y < $n_to && $this->ychanged[$y])
135 $adds[] = "" . $ylines[$y++];
136 $this->edits[] = $adds;
140 $this->edits[] = $endskip;
143 /* Divide the Largest Common Subsequence (LCS) of the sequences
144 * [XOFF, XLIM) and [YOFF, YLIM) into NCHUNKS approximately equally
147 * Returns (LCS, PTS). LCS is the length of the LCS. PTS is an
148 * array of NCHUNKS+1 (X, Y) indexes giving the diving points between
149 * sub sequences. The first sub-sequence is contained in [X0, X1),
150 * [Y0, Y1), the second in [X1, X2), [Y1, Y2) and so on. Note
151 * that (X0, Y0) == (XOFF, YOFF) and
152 * (X[NCHUNKS], Y[NCHUNKS]) == (XLIM, YLIM).
154 * This function assumes that the first lines of the specified portions
155 * of the two files do not match, and likewise that the last lines do not
156 * match. The caller must trim matching lines from the beginning and end
157 * of the portions it is going to specify.
159 function _diag ($xoff, $xlim, $yoff, $ylim, $nchunks)
163 if ($xlim - $xoff > $ylim - $yoff)
165 // Things seems faster (I'm not sure I understand why)
166 // when the shortest sequence in X.
168 list ($xoff, $xlim, $yoff, $ylim)
169 = array( $yoff, $ylim, $xoff, $xlim);
173 for ($i = $ylim - 1; $i >= $yoff; $i--)
174 $ymatches[$this->xv[$i]][] = $i;
176 for ($i = $ylim - 1; $i >= $yoff; $i--)
177 $ymatches[$this->yv[$i]][] = $i;
180 $this->seq[0]= $yoff - 1;
181 $this->in_seq = array();
184 $numer = $xlim - $xoff + $nchunks - 1;
186 for ($chunk = 0; $chunk < $nchunks; $chunk++)
189 for ($i = 0; $i <= $this->lcs; $i++)
190 $ymids[$i][$chunk-1] = $this->seq[$i];
192 $x1 = $xoff + (int)(($numer + ($xlim-$xoff)*$chunk) / $nchunks);
193 for ( ; $x < $x1; $x++)
195 $matches = $ymatches[$flip ? $this->yv[$x] : $this->xv[$x]];
199 while (list ($junk, $y) = each($matches))
200 if (empty($this->in_seq[$y]))
202 $k = $this->_lcs_pos($y);
203 USE_ASSERTS && assert($k > 0);
204 $ymids[$k] = $ymids[$k-1];
207 while (list ($junk, $y) = each($matches))
209 if ($y > $this->seq[$k-1])
211 USE_ASSERTS && assert($y < $this->seq[$k]);
212 // Optimization: this is a common case:
213 // next match is just replacing previous match.
214 $this->in_seq[$this->seq[$k]] = false;
216 $this->in_seq[$y] = 1;
218 else if (empty($this->in_seq[$y]))
220 $k = $this->_lcs_pos($y);
221 USE_ASSERTS && assert($k > 0);
222 $ymids[$k] = $ymids[$k-1];
228 $seps[] = $flip ? array($yoff, $xoff) : array($xoff, $yoff);
229 $ymid = $ymids[$this->lcs];
230 for ($n = 0; $n < $nchunks - 1; $n++)
232 $x1 = $xoff + (int)(($numer + ($xlim - $xoff) * $n) / $nchunks);
234 $seps[] = $flip ? array($y1, $x1) : array($x1, $y1);
236 $seps[] = $flip ? array($ylim, $xlim) : array($xlim, $ylim);
238 return array($this->lcs, $seps);
241 function _lcs_pos ($ypos)
244 if ($end == 0 || $ypos > $this->seq[$end])
246 $this->seq[++$this->lcs] = $ypos;
247 $this->in_seq[$ypos] = 1;
254 $mid = (int)(($beg + $end) / 2);
255 if ( $ypos > $this->seq[$mid] )
261 USE_ASSERTS && assert($ypos != $this->seq[$end]);
263 $this->in_seq[$this->seq[$end]] = false;
264 $this->seq[$end] = $ypos;
265 $this->in_seq[$ypos] = 1;
269 /* Find LCS of two sequences.
271 * The results are recorded in the vectors $this->{x,y}changed[], by
272 * storing a 1 in the element for each line that is an insertion
273 * or deletion (ie. is not in the LCS).
275 * The subsequence of file 0 is [XOFF, XLIM) and likewise for file 1.
277 * Note that XLIM, YLIM are exclusive bounds.
278 * All line numbers are origin-0 and discarded lines are not counted.
280 function _compareseq ($xoff, $xlim, $yoff, $ylim)
282 // Slide down the bottom initial diagonal.
283 while ($xoff < $xlim && $yoff < $ylim
284 && $this->xv[$xoff] == $this->yv[$yoff])
290 // Slide up the top initial diagonal.
291 while ($xlim > $xoff && $ylim > $yoff
292 && $this->xv[$xlim - 1] == $this->yv[$ylim - 1])
298 if ($xoff == $xlim || $yoff == $ylim)
302 // This is ad hoc but seems to work well.
303 //$nchunks = sqrt(min($xlim - $xoff, $ylim - $yoff) / 2.5);
304 //$nchunks = max(2,min(8,(int)$nchunks));
305 $nchunks = min(7, $xlim - $xoff, $ylim - $yoff) + 1;
307 = $this->_diag($xoff,$xlim,$yoff, $ylim,$nchunks);
312 // X and Y sequences have no common subsequence:
314 while ($yoff < $ylim)
315 $this->ychanged[$this->yind[$yoff++]] = 1;
316 while ($xoff < $xlim)
317 $this->xchanged[$this->xind[$xoff++]] = 1;
321 // Use the partitions to split this problem into subproblems.
324 while ($pt2 = next($seps))
326 $this->_compareseq ($pt1[0], $pt2[0], $pt1[1], $pt2[1]);
332 /* Adjust inserts/deletes of identical lines to join changes
333 * as much as possible.
335 * We do something when a run of changed lines include a
336 * line at one end and has an excluded, identical line at the other.
337 * We are free to choose which identical line is included.
338 * `compareseq' usually chooses the one at the beginning,
339 * but usually it is cleaner to consider the following identical line
340 * to be the "change".
342 * This is extracted verbatim from analyze.c (GNU diffutils-2.7).
344 function _shift_boundaries ($lines, &$changed, $other_changed)
349 USE_ASSERTS && assert('sizeof($lines) == sizeof($changed)');
350 $len = sizeof($lines);
351 $other_len = sizeof($other_changed);
356 * Scan forwards to find beginning of another run of changes.
357 * Also keep track of the corresponding point in the other file.
359 * Throughout this code, $i and $j are adjusted together so that
360 * the first $i elements of $changed and the first $j elements
361 * of $other_changed both contain the same number of zeros
363 * Furthermore, $j is always kept so that $j == $other_len or
364 * $other_changed[$j] == false.
366 while ($j < $other_len && $other_changed[$j])
369 while ($i < $len && ! $changed[$i])
371 USE_ASSERTS && assert('$j < $other_len && ! $other_changed[$j]');
373 while ($j < $other_len && $other_changed[$j])
382 // Find the end of this run of changes.
383 while (++$i < $len && $changed[$i])
389 * Record the length of this run of changes, so that
390 * we can later determine whether the run has grown.
392 $runlength = $i - $start;
395 * Move the changed region back, so long as the
396 * previous unchanged line matches the last changed one.
397 * This merges with previous changed regions.
399 while ($start > 0 && $lines[$start - 1] == $lines[$i - 1])
401 $changed[--$start] = 1;
402 $changed[--$i] = false;
403 while ($start > 0 && $changed[$start - 1])
405 USE_ASSERTS && assert('$j > 0');
406 while ($other_changed[--$j])
408 USE_ASSERTS && assert('$j >= 0 && !$other_changed[$j]');
412 * Set CORRESPONDING to the end of the changed run, at the last
413 * point where it corresponds to a changed run in the other file.
414 * CORRESPONDING == LEN means no such point has been found.
416 $corresponding = $j < $other_len ? $i : $len;
419 * Move the changed region forward, so long as the
420 * first changed line matches the following unchanged one.
421 * This merges with following changed regions.
422 * Do this second, so that if there are no merges,
423 * the changed region is moved forward as far as possible.
425 while ($i < $len && $lines[$start] == $lines[$i])
427 $changed[$start++] = false;
429 while ($i < $len && $changed[$i])
432 USE_ASSERTS && assert('$j < $other_len && ! $other_changed[$j]');
434 if ($j < $other_len && $other_changed[$j])
437 while ($j < $other_len && $other_changed[$j])
442 while ($runlength != $i - $start);
445 * If possible, move the fully-merged run of changes
446 * back to a corresponding run in the other file.
448 while ($corresponding < $i)
450 $changed[--$start] = 1;
452 USE_ASSERTS && assert('$j > 0');
453 while ($other_changed[--$j])
455 USE_ASSERTS && assert('$j >= 0 && !$other_changed[$j]');
462 * Class representing a diff between two files.
469 * Compute diff between files (or deserialize serialized WikiDiff.)
471 function WikiDiff($from_lines = false, $to_lines = false)
473 if ($from_lines && $to_lines)
475 $compute = new _WikiDiffEngine($from_lines, $to_lines);
476 $this->edits = $compute->edits;
478 else if ($from_lines)
480 // $from_lines is not really from_lines, but rather
481 // a serialized WikiDiff.
482 $this->edits = unserialize($from_lines);
486 $this->edits = array();
491 * Compute reversed WikiDiff.
495 * $diff = new WikiDiff($lines1, $lines2);
496 * $rev = $diff->reverse($lines1);
498 * // reconstruct $lines1 from $lines2:
499 * $out = $rev->apply($lines2);
501 function reverse ($from_lines)
506 for ( reset($this->edits);
507 $edit = current($this->edits);
511 { // Was an add, turn it into a delete.
512 $nadd = sizeof($edit);
513 USE_ASSERTS && assert ($nadd > 0);
518 // Was a copy --- just pass it through. }
522 { // Was a delete, turn it into an add.
525 while ($ndelete-- > 0)
526 $edit[] = "" . $from_lines[$x++];
528 else die("assertion error");
530 $rev->edits[] = $edit;
537 * Compose (concatenate) WikiDiffs.
541 * $diff1 = new WikiDiff($lines1, $lines2);
542 * $diff2 = new WikiDiff($lines2, $lines3);
543 * $comp = $diff1->compose($diff2);
545 * // reconstruct $lines3 from $lines1:
546 * $out = $comp->apply($lines1);
548 function compose ($that)
553 $comp = new WikiDiff;
554 $left = current($this->edits);
555 $right = current($that->edits);
557 while ($left || $right)
559 if (!is_array($left) && $left < 0)
560 { // Left op is a delete.
562 $left = next($this->edits);
564 else if (is_array($right))
565 { // Right op is an add.
567 $right = next($that->edits);
569 else if (!$left || !$right)
570 die ("assertion error");
571 else if (!is_array($left) && $left > 0)
572 { // Left op is a copy.
573 if ($left <= abs($right))
575 $newop = $right > 0 ? $left : -$left;
578 $right = next($that->edits);
579 $left = next($this->edits);
584 $left -= abs($right);
585 $right = next($that->edits);
589 { // Left op is an add.
590 if (!is_array($left)) die('assertion error');
591 $nleft = sizeof($left);
592 if ($nleft <= abs($right))
595 { // Right op is copy
599 else // Right op is delete
605 $right = next($that->edits);
606 $left = next($this->edits);
612 for ($i = 0; $i < $right; $i++)
613 $newop[] = $left[$i];
616 for ($i = abs($right); $i < $nleft; $i++)
619 $right = next($that->edits);
630 if (is_array($op) && is_array($newop))
632 // Both $op and $newop are adds.
633 for ($i = 0; $i < sizeof($newop); $i++)
636 else if (($op > 0 && $newop > 0) || ($op < 0 && $newop < 0))
637 { // $op and $newop are both either deletes or copies.
642 $comp->edits[] = $op;
647 $comp->edits[] = $op;
656 for (reset($this->edits);
657 $edit = current($this->edits);
664 echo "Delete " . -$edit;
665 else if (is_array($edit))
667 echo "Add " . sizeof($edit) . "<ul>";
668 for ($i = 0; $i < sizeof($edit); $i++)
669 echo "<li>" . htmlspecialchars($edit[$i]);
673 die("assertion error");
680 * Apply a WikiDiff to a set of lines.
684 * $diff = new WikiDiff($lines1, $lines2);
686 * // reconstruct $lines2 from $lines1:
687 * $out = $diff->apply($lines1);
689 function apply ($from_lines)
692 $xlim = sizeof($from_lines);
694 for ( reset($this->edits);
695 $edit = current($this->edits);
701 while (list ($junk, $line) = each($edit))
706 $output[] = $from_lines[$x++];
711 ExitWiki(sprintf(gettext ("WikiDiff::apply: line count mismatch: %s != %s"), $x, $xlim));
716 * Serialize a WikiDiff.
720 * $diff = new WikiDiff($lines1, $lines2);
721 * $string = $diff->serialize;
723 * // recover WikiDiff from serialized version:
724 * $diff2 = new WikiDiff($string);
726 function serialize ()
728 return serialize($this->edits);
732 * Return true if two files were equal.
736 if (sizeof($this->edits) > 1)
738 if (sizeof($this->edits) == 0)
740 // Test for: only edit is a copy.
741 return !is_array($this->edits[0]) && $this->edits[0] > 0;
745 * Compute the length of the Longest Common Subsequence (LCS).
747 * This is mostly for diagnostic purposed.
752 for (reset($this->edits);
753 $edit = current($this->edits);
756 if (!is_array($edit) && $edit > 0)
763 * Check a WikiDiff for validity.
765 * This is here only for debugging purposes.
767 function _check ($from_lines, $to_lines)
769 $test = $this->apply($from_lines);
770 if (serialize($test) != serialize($to_lines))
771 ExitWiki(gettext ("WikiDiff::_check: failed"));
774 $prev = current($this->edits);
775 $prevtype = is_array($prev) ? 'a' : ($prev > 0 ? 'c' : 'd');
777 while ($edit = next($this->edits))
779 $type = is_array($edit) ? 'a' : ($edit > 0 ? 'c' : 'd');
780 if ( $prevtype == $type )
781 ExitWiki(gettext ("WikiDiff::_check: edit sequence is non-optimal"));
785 printf ("<strong>" . gettext ("WikiDiff Okay: LCS = %s") . "</strong>\n", $lcs);
791 * A class to format a WikiDiff as HTML.
795 * $diff = new WikiDiff($lines1, $lines2); // compute diff.
797 * $fmt = new WikiDiffFormatter;
798 * echo $fmt->format($diff, $lines1); // Output HTMLified standard diff.
800 * or to output reverse diff (diff's that would take $lines2 to $lines1):
802 * $fmt = new WikiDiffFormatter('reversed');
803 * echo $fmt->format($diff, $lines1);
805 class WikiDiffFormatter
808 var $do_reverse_diff;
809 var $context_prefix, $deletes_prefix, $adds_prefix;
811 function WikiDiffFormatter ($reverse = false)
813 $this->do_reverse_diff = $reverse;
814 $this->context_lines = 0;
815 $this->context_prefix = ' ';
816 $this->deletes_prefix = '< ';
817 $this->adds_prefix = '> ';
820 function format ($diff, $from_lines)
822 return Element('table',
823 array('width' => '100%',
824 'bgcolor' => 'black',
828 $this->_format($diff->edits, $from_lines));
831 function _format ($edits, $from_lines)
835 $xlim = sizeof($from_lines);
838 while ($edit = current($edits))
840 if (!is_array($edit) && $edit >= 0)
841 { // Edit op is a copy.
849 // Start of an output hunk.
850 $xoff = max(0, $x - $this->context_lines);
851 $yoff = $xoff + $y - $x;
854 // Get leading context.
856 for ($i = $xoff; $i < $x; $i++)
857 $context[] = $from_lines[$i];
858 $hunk['c'] = $context;
862 { // Edit op is an add.
864 $hunk[$this->do_reverse_diff ? 'd' : 'a'] = $edit;
867 { // Edit op is a delete
870 $deletes[] = $from_lines[$x++];
871 $hunk[$this->do_reverse_diff ? 'a' : 'd'] = $deletes;
875 $next = next($edits);
878 if ( !$next || $ncopy > 2 * $this->context_lines)
880 // End of an output hunk.
884 $xend = min($x + $this->context_lines, $xlim);
887 // Get trailing context.
889 for ($i = $x; $i < $xend; $i++)
890 $context[] = $from_lines[$i];
891 $hunks[] = array('c' => $context);
894 $xlen = $xend - $xoff;
895 $ylen = $xend + $y - $x - $yoff;
896 $xbeg = $xlen ? $xoff + 1 : $xoff;
897 $ybeg = $ylen ? $yoff + 1 : $yoff;
899 if ($this->do_reverse_diff)
900 list ($xbeg, $xlen, $ybeg, $ylen)
901 = array($ybeg, $ylen, $xbeg, $xlen);
903 $rows .= $this->_emit_diff($xbeg,$xlen,$ybeg,$ylen, $hunks);
912 for ($i = $x; $i < $x + $ncopy; $i++)
913 $context[] = $from_lines[$i];
914 $hunk = array('c' => $context);
924 function _emit_lines($lines, $prefix, $color)
927 $prefix = Element('td', array('bgcolor' => '#cccccc'), $prefix);
929 while (list ($junk, $line) = each($lines))
931 $line = empty($line) ? ' ' : htmlspecialchars($line);
932 $html .= Element('tr',
933 $prefix . Element('td', array('bgcolor' => $color),
934 Element('tt', $line)));
939 function _emit_diff ($xbeg,$xlen,$ybeg,$ylen,$hunks)
941 $header = Element('tr', array('bgcolor' => '#cccccc'),
942 Element('td', array('colspan' => 2),
944 $this->_diff_header($xbeg, $xlen, $ybeg, $ylen))));
946 $prefix = array('c' => $this->context_prefix,
947 'a' => $this->adds_prefix,
948 'd' => $this->deletes_prefix);
949 $color = array('c' => '#ffffff',
954 for (reset($hunks); $hunk = current($hunks); next($hunks))
956 if (!empty($hunk['c']))
957 $diff .= $this->_emit_lines($hunk['c'],
958 $this->context_prefix, '#ffffff');
959 if (!empty($hunk['d']))
960 $diff .= $this->_emit_lines($hunk['d'],
961 $this->deletes_prefix, '#ccffcc');
962 if (!empty($hunk['a']))
963 $diff .= $this->_emit_lines($hunk['a'],
964 $this->adds_prefix, '#ffcccc');
968 return Element('tr', Element('td',
970 array('width' => '100%',
971 'bgcolor' => 'white',
979 function _diff_header ($xbeg,$xlen,$ybeg,$ylen)
981 $what = $xlen ? ($ylen ? 'c' : 'd') : 'a';
982 $xlen = $xlen > 1 ? "," . ($xbeg + $xlen - 1) : '';
983 $ylen = $ylen > 1 ? "," . ($ybeg + $ylen - 1) : '';
985 return "$xbeg$xlen$what$ybeg$ylen";
990 * A class to format a WikiDiff as a pretty HTML unified diff.
994 * $diff = new WikiDiff($lines1, $lines2); // compute diff.
996 * $fmt = new WikiUnifiedDiffFormatter;
997 * echo $fmt->format($diff, $lines1); // Output HTMLified unified diff.
999 class WikiUnifiedDiffFormatter extends WikiDiffFormatter
1001 function WikiUnifiedDiffFormatter ($reverse = false, $context_lines = 3)
1003 $this->do_reverse_diff = $reverse;
1004 $this->context_lines = $context_lines;
1005 $this->context_prefix = ' ';
1006 $this->deletes_prefix = '-';
1007 $this->adds_prefix = '+';
1010 function _diff_header ($xbeg,$xlen,$ybeg,$ylen)
1012 $xlen = $xlen == 1 ? '' : ",$xlen";
1013 $ylen = $ylen == 1 ? '' : ",$ylen";
1015 return "@@ -$xbeg$xlen +$ybeg$ylen @@";
1021 /////////////////////////////////////////////////////////////////
1023 function PageInfoRow ($label, $hash)
1025 global $datetimeformat;
1027 $cols = QElement('td', array('align' => 'right'), $label);
1030 if (is_array($hash)) {
1032 $cols .= QElement('td',
1033 sprintf(gettext ("version %s"), $version));
1034 $cols .= QElement('td',
1035 sprintf(gettext ("last modified on %s"),
1036 date($datetimeformat, $lastmodified)));
1037 $cols .= QElement('td',
1038 sprintf(gettext ("by %s"), $author));
1040 $cols .= QElement('td', array('colspan' => '3'),
1043 return Element('tr', $cols);
1046 if (isset($pagename))
1048 $wiki = RetrievePage($dbi, $pagename, $WikiPageStore);
1049 $archive= RetrievePage($dbi, $pagename, $ArchivePageStore);
1051 $html = Element('table',
1052 PageInfoRow(gettext ("Current page:"), $wiki)
1053 . PageInfoRow(gettext ("Archived page:"), $archive));
1057 if (is_array($wiki) && is_array($archive))
1059 $diff = new WikiDiff($archive['content'], $wiki['content']);
1060 if ($diff->isEmpty()) {
1061 $html .= '<hr>[' . gettext ("Versions are identical") . ']';
1063 //$fmt = new WikiDiffFormatter;
1064 $fmt = new WikiUnifiedDiffFormatter;
1065 $html .= $fmt->format($diff, $archive['content']);
1069 GeneratePage('MESSAGE', $html, sprintf(gettext ("Diff of %s."),
1070 htmlspecialchars($pagename)), 0);