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