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