| // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| /// Helper for creating HTML visualization of the source map information |
| /// generated by a [SourceMapProcessor]. |
| |
| library sourcemap.html.helper; |
| |
| import 'dart:convert'; |
| import 'dart:math' as Math; |
| |
| import 'package:compiler/src/io/source_file.dart'; |
| import 'package:compiler/src/io/source_information.dart'; |
| import 'package:compiler/src/js/js.dart' as js; |
| |
| import 'colors.dart'; |
| import 'sourcemap_helper.dart'; |
| import 'sourcemap_html_templates.dart'; |
| import 'html_parts.dart'; |
| |
| /// Truncate [input] to [length], adding '...' if truncated. |
| String truncate(String input, int length) { |
| if (input.length > length) { |
| return '${input.substring(0, length - 3)}...'; |
| } |
| return input; |
| } |
| |
| /// Returns the [index]th color for visualization. |
| HSV toColor(int index) { |
| int hueCount = 24; |
| double h = 360.0 * (index % hueCount) / hueCount; |
| double v = 1.0; |
| double s = 0.5; |
| return new HSV(h, s, v); |
| } |
| |
| /// Return the CSS color value for the [index]th color. |
| String toColorCss(int index) { |
| return toColor(index).toCss; |
| } |
| |
| /// Return the CSS color value for the [index]th span. |
| String toPattern(int index) { |
| /// Use gradient on spans to visually identify consecutive spans mapped to the |
| /// same source location. |
| HSV startColor = toColor(index); |
| HSV endColor = new HSV(startColor.h, startColor.s + 0.4, startColor.v - 0.2); |
| return 'linear-gradient(to right, ${startColor.toCss}, ${endColor.toCss})'; |
| } |
| |
| /// Return the html for the [index] line number. If [width] is provided, shorter |
| /// line numbers will be prefixed with spaces to match the width. |
| String lineNumber(int index, {int width, bool useNbsp: false}) { |
| String text = '${index + 1}'; |
| String padding = useNbsp ? ' ' : ' '; |
| if (width != null && text.length < width) { |
| text = (padding * (width - text.length)) + text; |
| } |
| return '<span class="lineNumber">$text$padding</span>'; |
| } |
| |
| /// Return the html escaped [text]. |
| String escape(String text) { |
| return const HtmlEscape().convert(text); |
| } |
| |
| /// Information needed to generate HTML for a single [SourceMapInfo]. |
| class SourceMapHtmlInfo { |
| final SourceMapInfo sourceMapInfo; |
| final CodeProcessor codeProcessor; |
| final SourceLocationCollection sourceLocationCollection; |
| |
| SourceMapHtmlInfo(this.sourceMapInfo, |
| this.codeProcessor, |
| this.sourceLocationCollection); |
| |
| String toString() { |
| return sourceMapInfo.toString(); |
| } |
| } |
| |
| /// A collection of source locations. |
| /// |
| /// Used to index source locations for visualization and linking. |
| class SourceLocationCollection { |
| List<SourceLocation> sourceLocations = []; |
| Map<SourceLocation, int> sourceLocationIndexMap; |
| |
| SourceLocationCollection([SourceLocationCollection parent]) |
| : sourceLocationIndexMap = |
| parent == null ? {} : parent.sourceLocationIndexMap; |
| |
| int registerSourceLocation(SourceLocation sourceLocation) { |
| return sourceLocationIndexMap.putIfAbsent(sourceLocation, () { |
| sourceLocations.add(sourceLocation); |
| return sourceLocationIndexMap.length; |
| }); |
| } |
| |
| int getIndex(SourceLocation sourceLocation) { |
| return sourceLocationIndexMap[sourceLocation]; |
| } |
| } |
| |
| abstract class CssColorScheme { |
| String singleLocationToCssColor(var id); |
| |
| String multiLocationToCssColor(List ids); |
| |
| bool get showLocationAsSpan; |
| } |
| |
| class CustomColorScheme implements CssColorScheme { |
| final bool showLocationAsSpan; |
| final Function single; |
| final Function multi; |
| |
| CustomColorScheme( |
| {this.showLocationAsSpan: false, |
| String this.single(var id), |
| String this.multi(List ids)}); |
| |
| String singleLocationToCssColor(var id) => single != null ? single(id) : null; |
| |
| String multiLocationToCssColor(List ids) => multi != null ? multi(ids) : null; |
| } |
| |
| class PatternCssColorScheme implements CssColorScheme { |
| const PatternCssColorScheme(); |
| |
| bool get showLocationAsSpan => true; |
| |
| String singleLocationToCssColor(int index) { |
| return "background:${toPattern(index)};"; |
| } |
| |
| String multiLocationToCssColor(List<int> indices) { |
| |
| StringBuffer sb = new StringBuffer(); |
| double delta = 100.0 / (indices.length); |
| double position = 0.0; |
| |
| void addColor(String color) { |
| sb.write(', ${color} ${position.toInt()}%'); |
| position += delta; |
| sb.write(', ${color} ${position.toInt()}%'); |
| } |
| |
| for (int index in indices) { |
| addColor('${toColorCss(index)}'); |
| } |
| return 'background: linear-gradient(to right${sb}); ' |
| 'background-size: 10px 10px;'; |
| } |
| } |
| |
| class SingleColorScheme implements CssColorScheme { |
| const SingleColorScheme(); |
| |
| bool get showLocationAsSpan => false; |
| |
| String singleLocationToCssColor(int index) { |
| return "background:${toColorCss(index)};"; |
| } |
| |
| String multiLocationToCssColor(List<int> indices) { |
| StringBuffer sb = new StringBuffer(); |
| double delta = 100.0 / (indices.length); |
| double position = 0.0; |
| |
| void addColor(String color) { |
| sb.write(', ${color} ${position.toInt()}%'); |
| position += delta; |
| sb.write(', ${color} ${position.toInt()}%'); |
| } |
| |
| for (int index in indices) { |
| addColor('${toColorCss(index)}'); |
| } |
| return 'background: linear-gradient(to bottom${sb}); ' |
| 'background-size: 10px 3px;'; |
| } |
| } |
| |
| /// Processor that computes the HTML representation of a block of JavaScript |
| /// code and collects the source locations mapped in the code. |
| class CodeProcessor { |
| int lineIndex = 0; |
| final String name; |
| int currentJsSourceOffset = 0; |
| final SourceLocationCollection collection; |
| final Map<int, List<SourceLocation>> codeLocations = {}; |
| final CssColorScheme colorScheme; |
| |
| CodeProcessor(this.name, this.collection, |
| {this.colorScheme: const PatternCssColorScheme()}); |
| |
| void addSourceLocation(int targetOffset, SourceLocation sourceLocation) { |
| codeLocations.putIfAbsent(targetOffset, () => []).add(sourceLocation); |
| collection.registerSourceLocation(sourceLocation); |
| } |
| |
| String convertToHtml(String text) { |
| List<Annotation> annotations = <Annotation>[]; |
| codeLocations.forEach((int codeOffset, List<SourceLocation> locations) { |
| for (SourceLocation location in locations) { |
| if (location != null) { |
| annotations.add(new Annotation( |
| collection.getIndex(location), |
| codeOffset, |
| location.shortText)); |
| } |
| } |
| }); |
| return convertAnnotatedCodeToHtml( |
| text, annotations, colorScheme: colorScheme, |
| elementScheme: new HighlightLinkScheme(name), |
| windowSize: 3); |
| } |
| } |
| |
| class Annotation { |
| final id; |
| final int codeOffset; |
| final String title; |
| |
| Annotation(this.id, this.codeOffset, this.title); |
| } |
| |
| class ElementScheme { |
| const ElementScheme(); |
| |
| String getName(var id, Set ids) => null; |
| String getHref(var id, Set ids) => null; |
| String onClick(var id, Set ids) => null; |
| String onMouseOver(var id, Set ids) => null; |
| String onMouseOut(var id, Set ids) => null; |
| } |
| |
| class HighlightLinkScheme implements ElementScheme { |
| final String name; |
| |
| HighlightLinkScheme(this.name); |
| |
| @override |
| String getName(int id, Set<int> indices) { |
| return 'js$id'; |
| } |
| |
| @override |
| String getHref(int id, Set<int> indices) { |
| return "#${id}"; |
| } |
| |
| @override |
| String onClick(int id, Set<int> indices) { |
| return "show(\'$name\');"; |
| } |
| |
| @override |
| String onMouseOut(int id, Set<int> indices) { |
| String onmouseover = indices.map((i) => '\'$i\'').join(','); |
| return "highlight([${onmouseover}]);"; |
| } |
| |
| @override |
| String onMouseOver(int id, Set<int> indices) { |
| return "highlight([]);"; |
| } |
| } |
| |
| String convertAnnotatedCodeToHtml( |
| String code, |
| Iterable<Annotation> annotations, |
| {CssColorScheme colorScheme: const SingleColorScheme(), |
| ElementScheme elementScheme: const ElementScheme(), |
| int windowSize}) { |
| StringBuffer htmlBuffer = new StringBuffer(); |
| List<CodeLine> lines = convertAnnotatedCodeToCodeLines( |
| code, annotations, |
| colorScheme: colorScheme, |
| elementScheme: elementScheme, |
| windowSize: windowSize); |
| int lineNoWidth; |
| if (lines.isNotEmpty) { |
| lineNoWidth = '${lines.last.lineNo + 1}'.length; |
| } |
| HtmlPrintContext context = new HtmlPrintContext(lineNoWidth: lineNoWidth); |
| for (CodeLine line in lines) { |
| line.printHtmlOn(htmlBuffer, context); |
| } |
| return htmlBuffer.toString(); |
| } |
| |
| List<CodeLine> convertAnnotatedCodeToCodeLines( |
| String code, |
| Iterable<Annotation> annotations, |
| {CssColorScheme colorScheme: const SingleColorScheme(), |
| ElementScheme elementScheme: const ElementScheme(), |
| int windowSize}) { |
| |
| List<CodeLine> lines = <CodeLine>[]; |
| CodeLine currentLine; |
| int offset = 0; |
| int lineIndex = 0; |
| int firstLine; |
| int lastLine; |
| bool pendingSourceLocationsEnd = false; |
| |
| void write(String code, HtmlPart html) { |
| if (currentLine != null) { |
| currentLine.codeBuffer.write(code); |
| currentLine.htmlParts.add(html); |
| } |
| } |
| |
| void startLine(int currentOffset) { |
| lines.add(currentLine = new CodeLine(lines.length, currentOffset)); |
| } |
| |
| void endCurrentLocation() { |
| if (pendingSourceLocationsEnd) { |
| write('', const ConstHtmlPart('</a>')); |
| } |
| pendingSourceLocationsEnd = false; |
| } |
| |
| void addSubstring(int until, {bool isFirst: false, bool isLast: false}) { |
| if (until <= offset) return; |
| if (offset >= code.length) return; |
| |
| String substring = code.substring(offset, until); |
| bool first = true; |
| |
| if (isLast) { |
| lastLine = lineIndex; |
| } |
| int localOffset = 0; |
| if (isFirst) { |
| startLine(offset + localOffset); |
| } |
| for (String line in substring.split('\n')) { |
| if (!first) { |
| endCurrentLocation(); |
| write('', const NewLine()); |
| lineIndex++; |
| startLine(offset + localOffset); |
| } |
| if (pendingSourceLocationsEnd && !colorScheme.showLocationAsSpan) { |
| if (line.isNotEmpty) { |
| String before = line.substring(0, 1); |
| write(before, new HtmlText(before)); |
| endCurrentLocation(); |
| String after = line.substring(1); |
| write(after, new HtmlText(after)); |
| } |
| } else { |
| write(line, new HtmlText(line)); |
| } |
| first = false; |
| localOffset += line.length + 1; |
| } |
| if (isFirst) { |
| firstLine = lineIndex; |
| } |
| offset = until; |
| } |
| |
| void insertAnnotations(List<Annotation> annotations) { |
| endCurrentLocation(); |
| |
| String color; |
| var id; |
| String title; |
| if (annotations.length == 1) { |
| Annotation annotation = annotations.single; |
| if (annotation != null) { |
| id = annotation.id; |
| color = colorScheme.singleLocationToCssColor(id); |
| title = annotation.title; |
| } |
| } else { |
| id = annotations.first.id; |
| List ids = []; |
| for (Annotation annotation in annotations) { |
| ids.add(annotation.id); |
| } |
| color = colorScheme.multiLocationToCssColor(ids); |
| title = annotations.map((l) => l.title).join(','); |
| } |
| if (id != null) { |
| Set ids = annotations.map((l) => l.id).toSet(); |
| String name = elementScheme.getName(id, ids); |
| String href = elementScheme.getHref(id, ids); |
| String onclick = elementScheme.onClick(id, ids); |
| String onmouseover = elementScheme.onMouseOver(id, ids); |
| String onmouseout = elementScheme.onMouseOut(id, ids); |
| write('', new AnchorHtmlPart( |
| color: color, |
| name: name, |
| href: href, |
| title: title, |
| onclick: onclick, |
| onmouseover: onmouseover, |
| onmouseout: onmouseout)); |
| pendingSourceLocationsEnd = true; |
| } |
| currentLine.annotations.addAll(annotations); |
| if (annotations.last == null) { |
| endCurrentLocation(); |
| } |
| } |
| |
| Map<int, List<Annotation>> annotationMap = <int, List<Annotation>>{}; |
| for (Annotation annotation in annotations) { |
| annotationMap.putIfAbsent(annotation.codeOffset, () => <Annotation>[]) |
| .add(annotation); |
| } |
| |
| bool first = true; |
| for (int codeOffset in annotationMap.keys.toList()..sort()) { |
| List<Annotation> annotationList = annotationMap[codeOffset]; |
| addSubstring(codeOffset, isFirst: first); |
| insertAnnotations(annotationList); |
| first = false; |
| } |
| |
| addSubstring(code.length, isFirst: first, isLast: true); |
| endCurrentLocation(); |
| |
| int start = 0; |
| int end = lines.length - 1; |
| if (windowSize != null) { |
| start = Math.max(firstLine - windowSize, start); |
| end = Math.min(lastLine + windowSize, end); |
| } |
| return lines.sublist(start, end); |
| } |
| |
| /// Computes the HTML representation for a collection of JavaScript code blocks. |
| String computeJsHtml(Iterable<SourceMapHtmlInfo> infoList) { |
| |
| StringBuffer jsCodeBuffer = new StringBuffer(); |
| for (SourceMapHtmlInfo info in infoList) { |
| String name = info.sourceMapInfo.name; |
| String html = info.codeProcessor.convertToHtml(info.sourceMapInfo.code); |
| String onclick = 'show(\'$name\');'; |
| jsCodeBuffer.write( |
| '<h3 onclick="$onclick">JS code for: ${escape(name)}</h3>\n'); |
| jsCodeBuffer.write(''' |
| <pre> |
| $html |
| </pre> |
| '''); |
| } |
| return jsCodeBuffer.toString(); |
| } |
| |
| /// Computes the HTML representation of the source mapping information for a |
| /// collection of JavaScript code blocks. |
| String computeJsTraceHtml(Iterable<SourceMapHtmlInfo> infoList) { |
| StringBuffer jsTraceBuffer = new StringBuffer(); |
| for (SourceMapHtmlInfo info in infoList) { |
| String name = info.sourceMapInfo.name; |
| String jsTrace = computeJsTraceHtmlPart( |
| info.sourceMapInfo.codePoints, info.sourceLocationCollection); |
| jsTraceBuffer.write(''' |
| <div name="$name" class="js-trace-buffer" style="display:none;"> |
| <h3>Trace for: ${escape(name)}</h3> |
| $jsTrace |
| </div> |
| '''); |
| } |
| return jsTraceBuffer.toString(); |
| } |
| |
| /// Computes the HTML information for the [info]. |
| SourceMapHtmlInfo createHtmlInfo(SourceLocationCollection collection, |
| SourceMapInfo info) { |
| js.Node node = info.node; |
| String code = info.code; |
| String name = info.name; |
| SourceLocationCollection subcollection = |
| new SourceLocationCollection(collection); |
| CodeProcessor codeProcessor = new CodeProcessor(name, subcollection); |
| for (js.Node node in info.nodeMap.nodes) { |
| info.nodeMap[node].forEach( |
| (int targetOffset, List<SourceLocation> sourceLocations) { |
| for (SourceLocation sourceLocation in sourceLocations) { |
| codeProcessor.addSourceLocation(targetOffset, sourceLocation); |
| } |
| }); |
| } |
| return new SourceMapHtmlInfo(info, codeProcessor, subcollection); |
| } |
| |
| /// Outputs a HTML file in [jsMapHtmlUri] containing an interactive |
| /// visualization of the source mapping information in [infoList] computed |
| /// with the [sourceMapProcessor]. |
| void createTraceSourceMapHtml(Uri jsMapHtmlUri, |
| SourceMapProcessor sourceMapProcessor, |
| Iterable<SourceMapInfo> infoList) { |
| SourceFileManager sourceFileManager = sourceMapProcessor.sourceFileManager; |
| SourceLocationCollection collection = new SourceLocationCollection(); |
| List<SourceMapHtmlInfo> htmlInfoList = <SourceMapHtmlInfo>[]; |
| for (SourceMapInfo info in infoList) { |
| htmlInfoList.add(createHtmlInfo(collection, info)); |
| } |
| |
| String jsCode = computeJsHtml(htmlInfoList); |
| String dartCode = computeDartHtml(sourceFileManager, htmlInfoList); |
| |
| String jsTraceHtml = computeJsTraceHtml(htmlInfoList); |
| outputJsDartTrace(jsMapHtmlUri, jsCode, dartCode, jsTraceHtml); |
| print('Trace source map html generated: $jsMapHtmlUri'); |
| } |
| |
| /// Computes the HTML representation for the Dart code snippets referenced in |
| /// [infoList]. |
| String computeDartHtml( |
| SourceFileManager sourceFileManager, |
| Iterable<SourceMapHtmlInfo> infoList) { |
| |
| StringBuffer dartCodeBuffer = new StringBuffer(); |
| for (SourceMapHtmlInfo info in infoList) { |
| dartCodeBuffer.write(computeDartHtmlPart(info.sourceMapInfo.name, |
| sourceFileManager, info.sourceLocationCollection)); |
| } |
| return dartCodeBuffer.toString(); |
| |
| } |
| |
| /// Computes the HTML representation for the Dart code snippets in [collection]. |
| String computeDartHtmlPart(String name, |
| SourceFileManager sourceFileManager, |
| SourceLocationCollection collection, |
| {bool showAsBlock: false}) { |
| const int windowSize = 3; |
| StringBuffer dartCodeBuffer = new StringBuffer(); |
| Map<Uri, Map<int, List<SourceLocation>>> sourceLocationMap = {}; |
| collection.sourceLocations.forEach((SourceLocation sourceLocation) { |
| Map<int, List<SourceLocation>> uriMap = |
| sourceLocationMap.putIfAbsent(sourceLocation.sourceUri, () => {}); |
| List<SourceLocation> lineList = |
| uriMap.putIfAbsent(sourceLocation.line, () => []); |
| lineList.add(sourceLocation); |
| }); |
| sourceLocationMap.forEach((Uri uri, Map<int, List<SourceLocation>> uriMap) { |
| SourceFile sourceFile = sourceFileManager.getSourceFile(uri); |
| if (sourceFile == null) return; |
| StringBuffer codeBuffer = new StringBuffer(); |
| |
| int firstLineIndex; |
| int lastLineIndex; |
| List<int> lineIndices = uriMap.keys.toList()..sort(); |
| int lineNoWidth; |
| if (lineIndices.isNotEmpty) { |
| lineNoWidth = '${lineIndices.last + windowSize + 1}'.length; |
| } |
| |
| void flush() { |
| if (firstLineIndex != null && lastLineIndex != null) { |
| dartCodeBuffer.write( |
| '<h4>${uri.pathSegments.last}, ' |
| '${firstLineIndex - windowSize + 1}-' |
| '${lastLineIndex + windowSize + 1}' |
| '</h4>\n'); |
| dartCodeBuffer.write('<pre>\n'); |
| for (int line = firstLineIndex - windowSize; |
| line < firstLineIndex; |
| line++) { |
| if (line >= 0) { |
| dartCodeBuffer.write(lineNumber(line, width: lineNoWidth)); |
| dartCodeBuffer.write(sourceFile.getLineText(line)); |
| } |
| } |
| dartCodeBuffer.write(codeBuffer); |
| for (int line = lastLineIndex + 1; |
| line <= lastLineIndex + windowSize; |
| line++) { |
| if (line < sourceFile.lines) { |
| dartCodeBuffer.write(lineNumber(line, width: lineNoWidth)); |
| dartCodeBuffer.write(sourceFile.getLineText(line)); |
| } |
| } |
| dartCodeBuffer.write('</pre>\n'); |
| firstLineIndex = null; |
| lastLineIndex = null; |
| } |
| codeBuffer.clear(); |
| } |
| |
| lineIndices.forEach((int lineIndex) { |
| List<SourceLocation> locations = uriMap[lineIndex]; |
| if (lastLineIndex != null && |
| lastLineIndex + windowSize * 4 < lineIndex) { |
| flush(); |
| } |
| if (firstLineIndex == null) { |
| firstLineIndex = lineIndex; |
| } else { |
| for (int line = lastLineIndex + 1; line < lineIndex; line++) { |
| codeBuffer.write(lineNumber(line, width: lineNoWidth)); |
| codeBuffer.write(sourceFile.getLineText(line)); |
| } |
| } |
| String line = sourceFile.getLineText(lineIndex); |
| locations.sort((a, b) => a.offset.compareTo(b.offset)); |
| for (int i = 0; i < locations.length; i++) { |
| SourceLocation sourceLocation = locations[i]; |
| int index = collection.getIndex(sourceLocation); |
| int start = sourceLocation.column; |
| int end = line.length; |
| if (i + 1 < locations.length) { |
| end = locations[i + 1].column; |
| } |
| if (i == 0) { |
| codeBuffer.write(lineNumber(lineIndex, width: lineNoWidth)); |
| codeBuffer.write(line.substring(0, start)); |
| } |
| codeBuffer.write( |
| '<a name="${index}" style="background:${toPattern(index)};" ' |
| 'title="[${lineIndex + 1},${start + 1}]" ' |
| 'onmouseover="highlight(\'$index\');" ' |
| 'onmouseout="highlight();">'); |
| codeBuffer.write(line.substring(start, end)); |
| codeBuffer.write('</a>'); |
| } |
| lastLineIndex = lineIndex; |
| }); |
| |
| flush(); |
| }); |
| String display = showAsBlock ? 'block' : 'none'; |
| return ''' |
| <div name="$name" class="dart-buffer" style="display:$display;"> |
| <h3>Dart code for: ${escape(name)}</h3> |
| ${dartCodeBuffer} |
| </div>'''; |
| } |
| |
| /// Computes a HTML visualization of the [codePoints]. |
| String computeJsTraceHtmlPart(List<CodePoint> codePoints, |
| SourceLocationCollection collection) { |
| StringBuffer buffer = new StringBuffer(); |
| buffer.write('<table style="width:100%;">'); |
| buffer.write( |
| '<tr><th>Node kind</th><th>JS code @ offset</th>' |
| '<th>Dart code @ mapped location</th><th>file:position:name</th></tr>'); |
| codePoints.forEach((CodePoint codePoint) { |
| String jsCode = truncate(codePoint.jsCode, 50); |
| if (codePoint.sourceLocation != null) { |
| int index = collection.getIndex(codePoint.sourceLocation); |
| if (index != null) { |
| String style = ''; |
| if (!codePoint.isMissing) { |
| style = 'style="background:${toColorCss(index)};" '; |
| } |
| buffer.write('<tr $style' |
| 'name="trace$index" ' |
| 'onmouseover="highlight([${index}]);"' |
| 'onmouseout="highlight([]);">'); |
| } else { |
| buffer.write('<tr>'); |
| print('${codePoint.sourceLocation} not found in '); |
| collection.sourceLocationIndexMap.keys |
| .where((l) => l.sourceUri == codePoint.sourceLocation.sourceUri) |
| .forEach((l) => print(' $l')); |
| } |
| } else { |
| buffer.write('<tr>'); |
| } |
| buffer.write('<td>${codePoint.kind}</td>'); |
| buffer.write('<td class="code">${jsCode}</td>'); |
| if (codePoint.sourceLocation == null) { |
| //buffer.write('<td></td>'); |
| } else { |
| String dartCode = truncate(codePoint.dartCode, 50); |
| buffer.write('<td class="code">${dartCode}</td>'); |
| buffer.write('<td>${escape(codePoint.sourceLocation.shortText)}</td>'); |
| } |
| buffer.write('</tr>'); |
| }); |
| buffer.write('</table>'); |
| |
| return buffer.toString(); |
| } |