1 /** 2 * Parses and handles Internet mail/news messages. 3 * 4 * License: 5 * This Source Code Form is subject to the terms of 6 * the Mozilla Public License, v. 2.0. If a copy of 7 * the MPL was not distributed with this file, You 8 * can obtain one at http://mozilla.org/MPL/2.0/. 9 * 10 * Authors: 11 * Vladimir Panteleev <vladimir@thecybershadow.net> 12 */ 13 14 module ae.net.ietf.message; 15 16 import std.algorithm; 17 import std.array; 18 import std.base64; 19 import std.conv; 20 import std.datetime; 21 import std.exception; 22 import std.regex; 23 import std.string; 24 import std.uri; 25 import std.utf; 26 27 // TODO: Replace with logging? 28 debug(RFC850) import std.stdio : stderr; 29 30 import ae.net.ietf.headers; 31 import ae.utils.array; 32 import ae.utils.iconv; 33 import ae.utils.mime; 34 import ae.utils.regex; 35 import ae.utils.text; 36 import ae.utils.time; 37 38 import ae.net.ietf.wrap; 39 40 alias ae.utils.text.ascii.ascii ascii; // https://d.puremagic.com/issues/show_bug.cgi?id=12156 41 alias std..string.indexOf indexOf; 42 43 struct Xref 44 { 45 string group; 46 int num; 47 } 48 49 class Rfc850Message 50 { 51 /// The raw message (as passed in a constructor). 52 ascii message; 53 54 /// The message ID, as specified at creation or in the Message-ID field. 55 /// Includes the usual angular brackets. 56 string id; 57 58 /// Cross-references - for newsgroups posts, list of groups where it was 59 /// posted, and article number in said group. 60 Xref[] xref; 61 62 /// The thread subject, with the leading "Re: " and list ID stripped. 63 string subject; 64 65 /// The original message subject, as it appears in the message. 66 string rawSubject; 67 68 /// The author's name, in UTF-8, stripped of quotes (no email address). 69 string author; 70 71 /// The author's email address, stripped of angular brackets. 72 string authorEmail; 73 74 /// Message date/time. 75 SysTime time; 76 77 /// A list of Message-IDs that this post is in reply to. 78 /// The most recent message (and direct parent) comes last. 79 string[] references; 80 81 /// Whether this post is a reply. 82 bool reply; 83 84 /// This message's headers. 85 Headers headers; 86 87 /// The text contents of this message (UTF-8). 88 /// "null" in case of an error. 89 string content; 90 91 /// The contents of this message (depends on mimeType). 92 ubyte[] data; 93 94 /// Explanation for null content. 95 string error; 96 97 /// Reflow options (RFC 2646). 98 bool flowed, delsp; 99 100 /// For a multipart message, contains the child parts. 101 /// May nest more than one level. 102 Rfc850Message[] parts; 103 104 /// Properties of a multipart message's part. 105 string name, fileName, description, mimeType; 106 107 /// Parses a message string and creates a Rfc850Message. 108 this(ascii message) 109 { 110 this.message = message; 111 debug(RFC850) scope(failure) stderr.writeln("Failure while parsing message: ", id); 112 113 // Split headers from message, parse headers 114 115 // TODO: this breaks binary encodings, FIXME 116 auto text = message.fastReplace("\r\n", "\n"); 117 auto headerEnd = text.indexOf("\n\n"); 118 if (headerEnd < 0) headerEnd = text.length; 119 auto header = text[0..headerEnd]; 120 header = header.fastReplace("\n\t", " ").fastReplace("\n ", " "); 121 122 // TODO: Use a proper spec-conforming header parser 123 foreach (s; header.fastSplit('\n')) 124 { 125 if (s == "") break; 126 127 auto p = s.indexOf(": "); 128 if (p<0) continue; 129 //assert(p>0, "Bad header line: " ~ s); 130 headers[s[0..p]] = s[p+2..$]; 131 } 132 133 // Decode international characters in headers 134 135 string defaultEncoding = guessDefaultEncoding(headers.get("User-Agent", null)); 136 137 foreach (string key, ref string value; headers) 138 if (hasHighAsciiChars(value)) 139 value = decodeEncodedText(value, defaultEncoding); 140 141 // Decode transfer encoding 142 143 ascii rawContent = text[min(headerEnd+2, $)..$]; 144 145 if ("Content-Transfer-Encoding" in headers) 146 try 147 rawContent = decodeTransferEncoding(rawContent, headers["Content-Transfer-Encoding"]); 148 catch (Exception e) 149 { 150 rawContent = null; 151 error = "Error decoding " ~ headers["Content-Transfer-Encoding"] ~ " message: " ~ e.msg; 152 } 153 154 // Decode message 155 156 data = cast(ubyte[])rawContent; 157 158 TokenHeader contentType, contentDisposition; 159 if ("Content-Type" in headers) 160 contentType = decodeTokenHeader(headers["Content-Type"]); 161 if ("Content-Disposition" in headers) 162 contentDisposition = decodeTokenHeader(headers["Content-Disposition"]); 163 mimeType = toLower(contentType.value); 164 flowed = contentType.properties.get("format", "fixed").icmp("flowed")==0; 165 delsp = contentType.properties.get("delsp", "no").icmp("yes") == 0; 166 167 if (rawContent) 168 { 169 if (!mimeType || mimeType == "text/plain") 170 { 171 if ("charset" in contentType.properties) 172 content = decodeEncodedText(rawContent, contentType.properties["charset"]); 173 else 174 content = decodeEncodedText(rawContent, defaultEncoding); 175 } 176 else 177 if (mimeType.startsWith("multipart/") && "boundary" in contentType.properties) 178 { 179 string boundary = contentType.properties["boundary"]; 180 auto end = rawContent.indexOf("--" ~ boundary ~ "--"); 181 if (end < 0) 182 end = rawContent.length; 183 rawContent = rawContent[0..end]; 184 185 auto rawParts = rawContent.split("--" ~ boundary ~ "\n"); 186 foreach (rawPart; rawParts[1..$]) 187 { 188 auto part = new Rfc850Message(rawPart); 189 if (part.content && !content) 190 content = part.content; 191 parts ~= part; 192 } 193 194 if (!content) 195 { 196 if (rawParts.length && rawParts[0].asciiStrip().length) 197 content = rawParts[0]; // default content to multipart stub 198 else 199 error = "Couldn't find text part in this " ~ mimeType ~ " message"; 200 } 201 } 202 else 203 error = "Don't know how parse " ~ mimeType ~ " message"; 204 } 205 206 // Strip PGP signature away to a separate "attachment" 207 208 enum PGP_START = "-----BEGIN PGP SIGNED MESSAGE-----\n"; 209 enum PGP_DELIM = "\n-----BEGIN PGP SIGNATURE-----\n"; 210 enum PGP_END = "\n-----END PGP SIGNATURE-----"; 211 if (content.startsWith(PGP_START) && 212 content.contains(PGP_DELIM) && 213 content.asciiStrip().endsWith(PGP_END)) 214 { 215 // Don't attempt to create meaningful signature files... just get the clutter out of the way 216 content = content.asciiStrip(); 217 auto p = content.indexOf(PGP_DELIM); 218 auto part = new Rfc850Message(content[p+PGP_DELIM.length..$-PGP_END.length]); 219 content = content[PGP_START.length..p]; 220 p = content.indexOf("\n\n"); 221 if (p >= 0) 222 content = content[p+2..$]; 223 part.fileName = "pgp.sig"; 224 parts ~= part; 225 } 226 227 // Decode UU-encoded attachments 228 229 if (content.contains("\nbegin ")) 230 { 231 auto r = regex(`^begin [0-7]+ \S+$`); 232 auto lines = content.split("\n"); 233 size_t start; 234 bool started; 235 string fn; 236 237 for (size_t i=0; i<lines.length; i++) 238 if (!started && !match(lines[i], r).empty) 239 { 240 start = i; 241 fn = lines[i].split(" ")[2]; 242 started = true; 243 } 244 else 245 if (started && lines[i] == "end" && lines[i-1]=="`") 246 { 247 started = false; 248 try 249 { 250 auto data = uudecode(lines[start+1..i]); 251 252 auto part = new Rfc850Message(); 253 part.fileName = fn; 254 part.mimeType = guessMime(fn); 255 part.data = data; 256 parts ~= part; 257 258 lines = lines[0..start] ~ lines[i+1..$]; 259 i = start-1; 260 } 261 catch (Exception e) 262 debug(RFC850) stderr.writeln(e); 263 } 264 265 content = lines.join("\n"); 266 } 267 268 // Parse message-part properties 269 270 name = contentType.properties.get("name", string.init); 271 fileName = contentDisposition.properties.get("filename", string.init); 272 description = headers.get("Content-Description", string.init); 273 if (name == fileName) 274 name = null; 275 276 // Decode references 277 278 if ("References" in headers) 279 { 280 reply = true; 281 auto refs = asciiStrip(headers["References"]); 282 while (refs.startsWith("<")) 283 { 284 auto p = refs.indexOf(">"); 285 if (p < 0) 286 break; 287 references ~= refs[0..p+1]; 288 refs = asciiStrip(refs[p+1..$]); 289 } 290 } 291 else 292 if ("In-Reply-To" in headers) 293 references = [headers["In-Reply-To"]]; 294 295 // Decode subject 296 297 subject = rawSubject = "Subject" in headers ? decodeRfc1522(headers["Subject"]) : null; 298 if (subject.startsWith("Re: ")) 299 { 300 subject = subject[4..$]; 301 reply = true; 302 } 303 304 // Decode author 305 306 static string[2] decodeAuthor(string header) 307 { 308 string author, authorEmail; 309 author = authorEmail = header; 310 if ((author.indexOf('@') < 0 && author.indexOf(" at ") >= 0) 311 || (author.indexOf("<") < 0 && author.indexOf(">") < 0 && author.indexOf(" (") > 0 && author.endsWith(")"))) 312 { 313 // Mailing list archive format 314 assert(author == authorEmail); 315 if (author.indexOf(" (") > 0 && author.endsWith(")")) 316 { 317 authorEmail = author[0 .. author.lastIndexOf(" (")].replace(" at ", "@"); 318 author = author[author.lastIndexOf(" (")+2 .. $-1].decodeRfc1522(); 319 } 320 else 321 { 322 authorEmail = author.replace(" at ", "@"); 323 author = author[0 .. author.lastIndexOf(" at ")]; 324 } 325 } 326 if (author.indexOf('<')>=0 && author.endsWith('>')) 327 { 328 auto p = author.indexOf('<'); 329 authorEmail = author[p+1..$-1]; 330 author = decodeRfc1522(asciiStrip(author[0..p])); 331 } 332 333 if (author.length>2 && author[0]=='"' && author[$-1]=='"') 334 author = decodeRfc1522(asciiStrip(author[1..$-1])); 335 if ((author == authorEmail || author == "") && authorEmail.indexOf("@") > 0) 336 author = authorEmail[0..authorEmail.indexOf("@")]; 337 return [author, authorEmail]; 338 } 339 340 list(author, authorEmail) = decodeAuthor("From" in headers ? decodeRfc1522(headers["From"]) : null); 341 342 if (headers.get("List-Post", null) == "<mailto:" ~ authorEmail ~ ">" && "Reply-To" in headers) 343 list(null, authorEmail) = decodeAuthor(decodeRfc1522(headers["Reply-To"].findSplit(", ")[0])); 344 345 // Decode cross-references 346 347 if ("Xref" in headers) 348 { 349 auto xrefStrings = split(headers["Xref"], " ")[1..$]; 350 foreach (str; xrefStrings) 351 { 352 auto segs = str.split(":"); 353 xref ~= Xref(segs[0], to!int(segs[1])); 354 } 355 } 356 else 357 if (headers.get("Sender", null).canFind("-bounces@") && !headers["Sender"].representation.any!(c => c.among('<', '>', '"'))) 358 xref = [Xref(headers["Sender"].findSplit("-bounces@")[0])]; 359 else 360 if ("List-Unsubscribe" in headers) 361 xref = headers["List-Unsubscribe"].split(", ").filter!(s => s.canFind("/options/")).map!(s => Xref(s.split("/")[$-1].stripRight('>'))).array(); 362 else 363 if ("List-ID" in headers) 364 { 365 auto listID = headers["List-ID"]; 366 auto parts = listID.findSplit(" <"); 367 if (parts[1].length) 368 listID = parts[2].findSplit(".")[0]; 369 else 370 listID = parts[0].replace(`"`, ``); 371 xref = [Xref(listID)]; 372 } 373 374 // Decode message ID 375 376 if ("Message-ID" in headers && !id) 377 id = headers["Message-ID"]; 378 379 // Decode post time 380 381 time = Clock.currTime; // default value 382 383 if ("NNTP-Posting-Date" in headers) 384 time = parseTime!`D, j M Y H:i:s O \(\U\T\C\)`(headers["NNTP-Posting-Date"].strip()); 385 else 386 if ("Date" in headers) 387 { 388 auto str = headers["Date"].strip(); 389 str = str.replace(re!`([+\-]\d\d\d\d) \(.*\)$`, "$1"); 390 try 391 time = parseTime!(TimeFormats.RFC850)(str); 392 catch (Exception e) 393 try 394 time = parseTime!(`D, j M Y H:i:s O`)(str); 395 catch (Exception e) 396 try 397 time = parseTime!(`D, j M Y H:i:s e`)(str); 398 catch (Exception e) 399 try 400 time = parseTime!(`D, j M Y H:i O`)(str); 401 catch (Exception e) 402 try 403 time = parseTime!(`D, j M Y H:i e`)(str); 404 catch (Exception e) 405 { 406 // fall-back to default (class creation time) 407 // TODO: better behavior? 408 } 409 } 410 } 411 412 private this() {} // for attachments and templates 413 414 /// Create a template Rfc850Message for a new posting to the specified groups. 415 static Rfc850Message newPostTemplate(string groups) 416 { 417 auto post = new Rfc850Message(); 418 foreach (group; groups.split(",")) 419 post.xref ~= Xref(group); 420 return post; 421 } 422 423 @property WrapFormat wrapFormat() 424 { 425 return flowed ? delsp ? WrapFormat.flowedDelSp : WrapFormat.flowed : WrapFormat.heuristics; 426 } 427 428 /// Create a template Rfc850Message for a reply to this message. 429 Rfc850Message replyTemplate() 430 { 431 auto post = new Rfc850Message(); 432 post.reply = true; 433 post.xref = this.xref; 434 post.references = this.references ~ this.id; 435 post.subject = this.rawSubject; 436 if (!post.subject.startsWith("Re:")) 437 post.subject = "Re: " ~ post.subject; 438 439 auto paragraphs = unwrapText(this.content, this.wrapFormat); 440 foreach (i, ref paragraph; paragraphs) 441 if (paragraph.quotePrefix.length) 442 paragraph.quotePrefix = ">" ~ paragraph.quotePrefix; 443 else 444 { 445 if (paragraph.text == "-- " || paragraph.text == "_______________________________________________") 446 { 447 paragraphs = paragraphs[0..i]; 448 break; 449 } 450 paragraph.quotePrefix = paragraph.text.length ? "> " : ">"; 451 } 452 while (paragraphs.length && paragraphs[$-1].text.length==0) 453 paragraphs = paragraphs[0..$-1]; 454 455 auto replyTime = time; 456 replyTime.timezone = UTC(); 457 post.content = 458 "On " ~ replyTime.formatTime!`l, j F Y \a\t H:i:s e`() ~ ", " ~ this.author ~ " wrote:\n" ~ 459 wrapText(paragraphs) ~ 460 "\n\n"; 461 post.flowed = true; 462 post.delsp = false; 463 464 return post; 465 } 466 467 /// Set the message text. 468 /// Rewraps as necessary. 469 void setText(string text) 470 { 471 this.content = wrapText(unwrapText(text, WrapFormat.input)); 472 this.flowed = true; 473 this.delsp = false; 474 } 475 476 /// Write this Message instance's fields to their appropriate headers. 477 void compileHeaders() 478 { 479 assert(id); 480 481 headers["Message-ID"] = id; 482 headers["From"] = format(`%s <%s>`, author, authorEmail); 483 headers["Subject"] = subject; 484 headers["Newsgroups"] = xref.map!(x => x.group)().join(","); 485 headers["Content-Type"] = format("text/plain; charset=utf-8; format=%s; delsp=%s", flowed ? "flowed" : "fixed", delsp ? "yes" : "no"); 486 headers["Content-Transfer-Encoding"] = "8bit"; 487 if (references.length) 488 { 489 headers["References"] = references.join(" "); 490 headers["In-Reply-To"] = references[$-1]; 491 } 492 if (time == SysTime.init) 493 time = Clock.currTime(); 494 headers["Date"] = time.formatTime!(TimeFormats.RFC2822); 495 headers["User-Agent"] = "ae.net.ietf.message"; 496 } 497 498 /// Construct the headers and message fields. 499 void compile() 500 { 501 compileHeaders(); 502 503 string[] lines; 504 foreach (name, value; headers) 505 { 506 if (value.hasHighAsciiChars()) 507 value = value.encodeRfc1522(); 508 auto line = name ~ ": " ~ value; 509 auto lineStart = name.length + 2; 510 511 foreach (c; line) 512 enforce(c >= 32, "Control characters in header: %(%s%)".format([line])); 513 514 while (line.length >= 80) 515 { 516 auto p = line[0..80].lastIndexOf(' '); 517 if (p < lineStart) 518 { 519 p = 80 + line[80..$].indexOf(' '); 520 if (p < 80) 521 break; 522 } 523 lines ~= line[0..p]; 524 line = line[p..$]; 525 lineStart = 1; 526 } 527 lines ~= line; 528 } 529 530 message = 531 lines.join("\r\n") ~ 532 "\r\n\r\n" ~ 533 splitAsciiLines(content).join("\r\n"); 534 } 535 536 /// Get the Message-ID that this message is in reply to. 537 @property string parentID() 538 { 539 return references.length ? references[$-1] : null; 540 } 541 542 /// Return the oldest known ancestor of this post, possibly 543 /// this post's ID if it is the first one in the thread. 544 /// May not be the thread ID - some UAs/services 545 /// cut off or strip the "References" header. 546 @property string firstAncestorID() 547 { 548 return references.length ? references[0] : id; 549 } 550 } 551 552 unittest 553 { 554 auto post = new Rfc850Message("From: msonke at example.org (=?ISO-8859-1?Q?S=F6nke_Martin?=)\n\nText"); 555 assert(post.author == "Sönke Martin"); 556 assert(post.authorEmail == "msonke@example.org"); 557 558 post = new Rfc850Message("Date: Tue, 06 Sep 2011 14:52 -0700\n\nText"); 559 assert(post.time.year == 2011); 560 561 post = new Rfc850Message("List-ID: phobos\nSubject: [phobos] Subject\n\nText"); 562 assert(post.xref == [Xref("phobos")]); 563 564 post = new Rfc850Message("Sender: dmd-beta-bounces@puremagic.com\n\nText"); 565 assert(post.xref == [Xref("dmd-beta")]); 566 } 567 568 private: 569 570 /// Decode headers with international characters in them. 571 string decodeRfc1522(string str) 572 { 573 auto words = str.split(" "); 574 bool[] encoded = new bool[words.length]; 575 576 foreach (wordIndex, ref word; words) 577 if (word.length > 6 && word.startsWith("=?") && word.endsWith("?=")) 578 { 579 auto parts = split(word[2..$-2], "?"); 580 if (parts.length != 3) 581 continue; 582 auto charset = parts[0]; 583 auto encoding = parts[1]; 584 auto text = parts[2]; 585 586 switch (toUpper(encoding)) 587 { 588 case "Q": 589 text = decodeQuotedPrintable(text, true); 590 break; 591 case "B": 592 text = cast(ascii)Base64.decode(text); 593 break; 594 default: 595 continue /*foreach*/; 596 } 597 598 word = decodeEncodedText(text, charset); 599 encoded[wordIndex] = true; 600 } 601 602 string result; 603 foreach (wordIndex, word; words) 604 { 605 if (wordIndex > 0 && !(encoded[wordIndex-1] && encoded[wordIndex])) 606 result ~= ' '; 607 result ~= word; 608 } 609 610 try 611 { 612 import std.utf; 613 validate(result); 614 } 615 catch (Exception e) 616 result = toUtf8(cast(ascii)result, "ISO-8859-1", true); 617 618 return result; 619 } 620 621 /// Encodes an UTF-8 string to be used in headers. 622 string encodeRfc1522(string str) 623 { 624 if (!str.hasHighAsciiChars()) 625 return str; 626 627 string[] words; 628 bool wasIntl = false; 629 foreach (word; str.split(" ")) 630 { 631 bool isIntl = word.hasHighAsciiChars(); 632 if (wasIntl && isIntl) 633 words[$-1] ~= " " ~ word; 634 else 635 words ~= word; 636 wasIntl = isIntl; 637 } 638 639 enum CHUNK_LENGTH_THRESHOLD = 20; 640 641 foreach (ref word; words) 642 { 643 if (!word.hasHighAsciiChars()) 644 continue; 645 string[] output; 646 string s = word; 647 while (s.length) 648 { 649 size_t ptr = 0; 650 while (ptr < s.length && ptr < CHUNK_LENGTH_THRESHOLD) 651 ptr += stride(s, ptr); 652 output ~= encodeRfc1522Chunk(s[0..ptr]); 653 s = s[ptr..$]; 654 } 655 word = output.join(" "); 656 } 657 return words.join(" "); 658 } 659 660 string encodeRfc1522Chunk(string str) pure 661 { 662 auto result = "=?UTF-8?B?" ~ Base64.encode(cast(ubyte[])str) ~ "?="; 663 return result; 664 } 665 666 unittest 667 { 668 auto text = "В лесу родилась ёлочка"; 669 assert(decodeRfc1522(encodeRfc1522(text)) == text); 670 671 // Make sure email address isn't mangled 672 assert(encodeRfc1522("Sönke Martin <msonke@example.org>").endsWith(" <msonke@example.org>")); 673 } 674 675 string decodeQuotedPrintable(string s, bool inHeaders) 676 { 677 auto r = appender!string(); 678 for (int i=0; i<s.length; ) 679 if (s[i]=='=') 680 { 681 if (i+1 >= s.length || s[i+1] == '\n') 682 i+=2; // escape newline 683 else 684 r.put(cast(char)to!ubyte(s[i+1..i+3], 16)), i+=3; 685 } 686 else 687 if (s[i]=='_' && inHeaders) 688 r.put(' '), i++; 689 else 690 r.put(s[i++]); 691 return r.data; 692 } 693 694 string guessDefaultEncoding(string userAgent) 695 { 696 switch (userAgent) 697 { 698 case "DFeed": 699 // Early DFeed versions did not specify the encoding 700 return "utf8"; 701 default: 702 return "windows1252"; 703 } 704 } 705 706 // http://d.puremagic.com/issues/show_bug.cgi?id=7016 707 static import ae.sys.cmd; 708 709 string decodeEncodedText(ascii s, string textEncoding) 710 { 711 try 712 return toUtf8(s, textEncoding, false); 713 catch (Exception e) 714 { 715 debug(RFC850) stderr.writefln("iconv fallback for %s (%s)", textEncoding, e.msg); 716 try 717 { 718 import ae.sys.cmd; 719 return iconv(s, textEncoding); 720 } 721 catch (Exception e) 722 { 723 debug(RFC850) stderr.writefln("ISO-8859-1 fallback (%s)", e.msg); 724 return toUtf8(s, "ISO-8859-1", false); 725 } 726 } 727 } 728 729 string decodeTransferEncoding(string data, string encoding) 730 { 731 switch (toLower(encoding)) 732 { 733 case "7bit": 734 return data; 735 case "quoted-printable": 736 return decodeQuotedPrintable(data, false); 737 case "base64": 738 //return cast(string)Base64.decode(data.replace("\n", "")); 739 { 740 auto s = data.fastReplace("\n", ""); 741 scope(failure) debug(RFC850) stderr.writeln(s); 742 return cast(string)Base64.decode(s); 743 } 744 default: 745 return data; 746 } 747 } 748 749 ubyte[] uudecode(string[] lines) 750 { 751 // TODO: optimize 752 //auto data = appender!(ubyte[]); // OPTLINK says no 753 ubyte[] data; 754 foreach (line; lines) 755 { 756 if (!line.length || line.startsWith("`")) 757 continue; 758 ubyte len = to!ubyte(line[0] - 32); 759 line = line[1..$]; 760 while (line.length % 4) 761 line ~= 32; 762 ubyte[] lineData; 763 while (line.length) 764 { 765 uint v = 0; 766 foreach (c; line[0..4]) 767 if (c == '`') // same as space 768 v <<= 6; 769 else 770 { 771 enforce(c >= 32 && c < 96, [c]); 772 v = (v<<6) | (c - 32); 773 } 774 775 auto a = cast(ubyte[])((&v)[0..1]); 776 lineData ~= a[2]; 777 lineData ~= a[1]; 778 lineData ~= a[0]; 779 780 line = line[4..$]; 781 } 782 while (len > lineData.length) 783 lineData ~= 0; 784 data ~= lineData[0..len]; 785 } 786 return data; 787 }