using System; using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; namespace Paca.Browser.Content; public sealed class MhtmlWebArchivePackager { public string Package(WebArchivePackageRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); var boundary = "----=_NextPart_" + Guid.NewGuid().ToString("N"); var sb = new StringBuilder(); sb.AppendLine("From: "); sb.AppendLine($"Snapshot-Content-Location: {request.Url ?? string.Empty}"); sb.AppendLine($"Subject: {request.Title ?? "Untitled"}"); sb.AppendLine($"Date: {DateTime.UtcNow:R}"); sb.AppendLine("MIME-Version: 1.0"); sb.AppendLine($"Content-Type: multipart/related; boundary=\"{boundary}\"; type=\"text/html\""); sb.AppendLine(); // HTML Part sb.AppendLine($"--{boundary}"); sb.AppendLine("Content-Type: text/html; charset=\"utf-8\""); sb.AppendLine("Content-Transfer-Encoding: 8bit"); sb.AppendLine($"Content-Location: {request.Url ?? string.Empty}"); sb.AppendLine(); sb.AppendLine(request.HtmlContent ?? string.Empty); sb.AppendLine(); // Resources if (request.Resources != null) { foreach (var res in request.Resources) { if (res == null) continue; var data = res.Data ?? Array.Empty(); sb.AppendLine($"--{boundary}"); sb.AppendLine($"Content-Type: {res.MimeType ?? "application/octet-stream"}"); sb.AppendLine("Content-Transfer-Encoding: base64"); sb.AppendLine($"Content-Location: {res.Url ?? string.Empty}"); sb.AppendLine(); sb.AppendLine(Convert.ToBase64String(data)); sb.AppendLine(); } } sb.AppendLine($"--{boundary}--"); return sb.ToString(); } public WebArchiveUnpackResult Unpack(string mhtmlContent) { if (string.IsNullOrWhiteSpace(mhtmlContent)) { return new WebArchiveUnpackResult("Untitled", string.Empty, string.Empty, Array.Empty()); } var locMatch = Regex.Match(mhtmlContent, @"Snapshot-Content-Location:\s*([^\r\n]+)", RegexOptions.IgnoreCase); var locationUrl = locMatch.Success ? locMatch.Groups[1].Value.Trim() : string.Empty; var subjMatch = Regex.Match(mhtmlContent, @"Subject:\s*([^\r\n]+)", RegexOptions.IgnoreCase); var title = subjMatch.Success ? subjMatch.Groups[1].Value.Trim() : "Untitled"; var boundMatch = Regex.Match(mhtmlContent, @"boundary=""?([^"";\r\n]+)""?", RegexOptions.IgnoreCase); if (!boundMatch.Success) { return new WebArchiveUnpackResult(title, locationUrl, mhtmlContent, Array.Empty()); } var boundary = boundMatch.Groups[1].Value; var parts = mhtmlContent.Split(new[] { "--" + boundary }, StringSplitOptions.RemoveEmptyEntries); string htmlContent = string.Empty; var resources = new List(); foreach (var part in parts) { if (part.StartsWith("--") || string.IsNullOrWhiteSpace(part)) continue; var doubleLine = part.IndexOf("\r\n\r\n", StringComparison.Ordinal); if (doubleLine < 0) doubleLine = part.IndexOf("\n\n", StringComparison.Ordinal); if (doubleLine < 0) continue; var headers = part.Substring(0, doubleLine); var body = part.Substring(doubleLine).Trim(); var typeMatch = Regex.Match(headers, @"Content-Type:\s*([^;\r\n]+)", RegexOptions.IgnoreCase); var mimeType = typeMatch.Success ? typeMatch.Groups[1].Value.Trim() : "text/html"; if (mimeType.Contains("multipart/related", StringComparison.OrdinalIgnoreCase) || headers.Contains("boundary=", StringComparison.OrdinalIgnoreCase)) { continue; } var urlMatch = Regex.Match(headers, @"Content-Location:\s*([^\r\n]+)", RegexOptions.IgnoreCase); var resUrl = urlMatch.Success ? urlMatch.Groups[1].Value.Trim() : string.Empty; if (mimeType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) { htmlContent = body; } else { byte[] data; if (headers.Contains("base64", StringComparison.OrdinalIgnoreCase)) { try { data = Convert.FromBase64String(body); } catch { data = Encoding.UTF8.GetBytes(body); } } else { data = Encoding.UTF8.GetBytes(body); } resources.Add(new WebArchiveResource(resUrl, mimeType, data)); } } return new WebArchiveUnpackResult(title, locationUrl, htmlContent, resources.AsReadOnly()); } }