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