-
Notifications
You must be signed in to change notification settings - Fork 36
Expand file tree
/
Copy pathPdf.cs
More file actions
298 lines (266 loc) · 16.7 KB
/
Copy pathPdf.cs
File metadata and controls
298 lines (266 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using PdfSharp.Pdf;
using PdfSharp.Pdf.IO;
using RT.Json;
using RT.Servers;
using RT.TagSoup;
using RT.Util;
using RT.Util.ExtensionMethods;
namespace KtaneWeb
{
public sealed partial class KtanePropellerModule
{
private HttpResponse pdf(HttpRequest req)
{
if (!req.Url.Path.StartsWith("/PDF/", StringComparison.InvariantCultureIgnoreCase))
return null;
var filename = req.Url.Path[5..];
if (filename.Length < 1 || filename.Contains('/'))
return null;
try { filename = filename.UrlUnescape(); }
catch (Exception) { return HttpResponse.Empty(HttpStatusCode._400_BadRequest); }
// If the PDF file already exists in the PDF folder, use that
var existingPdfPath = Path.Combine(_config.BaseDir, "PDF", filename);
if (File.Exists(existingPdfPath))
return null;
// See if an equivalent HTML file exists, even with a wildcard match or incorrect filename capitilization
var htmlFile = new DirectoryInfo(Path.Combine(_config.BaseDir, "HTML")).GetFiles(Path.GetFileNameWithoutExtension(filename) + ".html").Select(fs => fs.FullName).FirstOrDefault();
if (htmlFile == null)
return null;
// Check if the PDF filename is exactly correct and redirect if it isn’t
var pdfUrl = $"/PDF/{Path.GetFileNameWithoutExtension(htmlFile)}.pdf";
if (!Regex.IsMatch(pdfUrl, $"^{Regex.Escape("/PDF/" + filename).Replace("\\*", ".*")}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
return null;
if (pdfUrl != req.Url.Path[..5] + filename)
return HttpResponse.Redirect(req.Url.WithPath(pdfUrl));
// Turns out an HTML file corresponding to the requested PDF file exists, so we will try to generate the PDF automatically by invoking Google Chrome
KtaneModuleInfo module = null;
try { module = _moduleInfoCache.Modules.First(mod => mod.FileName == Path.GetFileNameWithoutExtension(filename)); }
catch { }
return HttpResponse.File(generatePdf(htmlFile, false, module?.PageRenderTime ?? defaultRenderDelay).filename, "application/pdf");
}
private const int defaultRenderDelay = 500;
/// <summary>
/// If <paramref name="avoidGenerating"/> is <c>false</c>, generates a PDF for the specified HTML file. If
/// <paramref name="avoidGenerating"/> is <c>true</c>, examines whether a PDF would need to be generated.</summary>
/// <param name="htmlFile">
/// Full path to the HTML file.</param>
/// <param name="avoidGenerating">
/// If <c>true</c>, no PDFs are generated.</param>
/// <returns>
/// The filename of the generated (or would-be generated) PDF, and a boolean specifying whether the PDF was
/// generated/would be generated (<c>true</c>) or was already available (<c>false</c>).</returns>
private (string filename, bool wasGenerated) generatePdf(string htmlFile, bool avoidGenerating = false, int renderDelay = defaultRenderDelay)
{
var cache = _moduleInfoCache;
lock (cache.AutogeneratedPdfs)
{
if (cache.AutogeneratedPdfs.TryGetValue(htmlFile, out var pdfFile) && File.Exists(pdfFile))
{
File.SetLastWriteTimeUtc(pdfFile, DateTime.UtcNow);
return (pdfFile, false);
}
var tempFilename = $"{MD5.HashData(File.ReadAllBytes(htmlFile)).ToHex()}.pdf";
var tempFilepath = Path.Combine(_config.PdfTempPath ?? Path.GetTempPath(), tempFilename);
var didGenerate = false;
if (!File.Exists(tempFilepath))
{
if (avoidGenerating)
return (tempFilepath, true);
var runner = new CommandRunner();
runner.Command = $@"cmd.exe /S /C """"{_config.ChromePath}"" --headless --disable-gpu ""--print-to-pdf={tempFilepath}"" ""--virtual-time-budget={renderDelay}"" --run-all-compositor-stages-before-draw --no-margins ""file:///{htmlFile.Replace('\\', '/').UrlEscape()}""""";
runner.StartAndWait();
didGenerate = true;
}
else
File.SetLastAccessTimeUtc(tempFilepath, DateTime.UtcNow);
cache.AutogeneratedPdfs[htmlFile] = tempFilepath;
return (tempFilepath, didGenerate);
}
}
private HttpResponse mergePdfs(HttpRequest req)
{
var lastExaminedPdfFile = "<none>";
var language = req.Headers.Cookie.Get("lang", null)?.Value ?? "en";
var langName = TranslationInfo.LanguageCodeToName.Get(language, "English");
try
{
if (req.Method != HttpMethod.Post)
return HttpResponse.Redirect(req.Url.WithPathParent().WithPath(""));
var messages = new StringBuilder();
var json = JsonValue.Parse(req.Post["json"].Value);
json.AppendIndented(messages);
var keywords = json["search"].GetString().Length == 0 ? null : json["search"].GetString().Split([' '], StringSplitOptions.RemoveEmptyEntries);
var searchOptions = json["searchOptions"].GetList().Select(j => j.GetString()).ToArray();
var filterEnabledByProfile = json["filterEnabledByProfile"].GetBool();
var filterVetoedByProfile = json["filterVetoedByProfile"].GetBool();
var profileVetoList = (filterEnabledByProfile == filterVetoedByProfile) ? null : json["profileVetoList"]?.GetList().Where(j => j != null).Select(j => j.GetString()).ToArray();
var searchBySymbol = json["searchBySymbol"].GetBoolSafe() ?? false;
var searchBySteamID = json["searchBySteamID"].GetBoolSafe() ?? false;
var searchByModuleID = json["searchByModuleID"].GetBoolSafe() ?? false;
var displayAllContributors = json["dispAllContr"].GetBoolSafe() ?? false;
var displayDesc = json["displayDesc"].GetBoolSafe() ?? false;
var displayTags = json["displayTags"].GetBoolSafe() ?? false;
var restrictedManuals = json["restrictedManuals"].GetList().Select(j => j.GetString()).ToArray();
static string unifyString(string str) => str.Normalize(NormalizationForm.FormD).RegexReplace(@"[\u0300-\u036f]", "").Replace("grey", "gray").Replace("colour", "color");
// Filter
var matchingModules = _moduleInfoCache.Modules.Where(m =>
{
// TEMPORARY: Currently there is no easy way to find the correct filename for the manual of a translated module, so we’re excluding those from the merged PDF entirely.
// A desirable fix would be to discover the correct PDF filename for the translated manual and include it.
if (m.TranslationOf != null)
return false;
if (profileVetoList != null && !(profileVetoList.Contains(m.ModuleID) ? filterVetoedByProfile : filterEnabledByProfile))
return false;
foreach (var filter in TranslationInfo.Default.Filters1)
if (!filter.Matches(m, json["filter"].Safe[filter.PropName].GetDictSafe()))
return false;
foreach (var filter in TranslationInfo.Default.Filters2)
if (!filter.Matches(m, json["filter"].Safe[filter.PropName].GetDictSafe()))
return false;
if (keywords == null)
return true;
var searchWhat = searchBySteamID ? (m.SteamID ?? "") : "";
if (searchByModuleID)
searchWhat += " " + m.ModuleID.ToLowerInvariant();
if (searchOptions.Contains("names"))
searchWhat += " " + m.Name.ToLowerInvariant() + " " + m.SortKey.ToLowerInvariant();
if (searchOptions.Contains("authors") && (m.Author != null || m.Contributors != null))
if (displayAllContributors)
searchWhat += " " + (m.Author ?? m.Contributors.ToAllAuthorString()).ToLowerInvariant();
else
searchWhat += " " + (m.Author ?? m.Contributors.ToAuthorString()).ToLowerInvariant();
if (searchOptions.Contains("descriptions"))
{
var descr = m.Descriptions.FirstOrDefault(d => d.Language == langName);
if (descr != null && displayDesc)
searchWhat += ' ' + descr?.Description.ToLowerInvariant();
if (descr != null && displayTags && !string.IsNullOrWhiteSpace(descr.Tags))
searchWhat += ' ' + descr?.Tags.ToLowerInvariant();
}
if (searchBySymbol && m.Symbol != null)
searchWhat += " " + m.Symbol.ToLowerInvariant();
return keywords.All(unifyString(searchWhat).ContainsIgnoreCase);
});
// Sort
switch (json["sort"].GetString())
{
case "name": matchingModules = matchingModules.OrderBy(m => m.SortKey); break;
case "defdiff": matchingModules = matchingModules.OrderBy(m => m.DefuserDifficulty); break;
case "expdiff": matchingModules = matchingModules.OrderBy(m => m.ExpertDifficulty); break;
case "twitchscore": matchingModules = matchingModules.OrderBy(m => m.TwitchPlaysScore ?? 0); break;
case "timemodescore": matchingModules = matchingModules.OrderBy(m => m.TimeMode?.Score ?? 0); break;
case "published": matchingModules = matchingModules.OrderByDescending(m => m.Published); break;
}
var pdfFiles = new List<string>();
var generated = 0;
var notGenerated = new List<string>();
var startTime = DateTime.UtcNow;
foreach (var module in matchingModules)
{
var filename = $"{module.FileName}.pdf";
lastExaminedPdfFile = filename;
string fullPath = null;
if (json["preferredManuals"].ContainsKey(module.Name))
{
var pref = json["preferredManuals"][module.Name].GetString();
var hasMatch = pref.RegexMatch(@"^(.*) \((PDF|HTML)\)$", out var match);
var fullname = $"{module.Name} {match.Groups[1].Value}";
var unrestricted = !restrictedManuals.Contains(fullname);
// PDF file exists
if (hasMatch && unrestricted && match.Groups[2].Value == "PDF"
&& Path.Combine(_config.BaseDir, "PDF", $"{fullname.Replace(module.Name, module.FileName)}.pdf") is { } path
&& File.Exists(path))
{
messages.AppendLine($"{pref} (pref) ⇒ {path}");
fullPath = path;
}
// HTML file exists, regardless if HTML or PDF is selected
else if (hasMatch && unrestricted
&& Path.Combine(_config.BaseDir, "HTML", $"{fullname.Replace(module.Name, module.FileName)}.html") is { } htmlPath
&& File.Exists(htmlPath))
{
messages.AppendLine($"{pref} (pref) ⇒ {htmlPath}");
fullPath = htmlPath;
}
}
if (fullPath == null)
{
fullPath = Path.Combine(_config.BaseDir, _config.PdfDir, filename);
if (!File.Exists(fullPath))
fullPath = Path.Combine(_config.BaseDir, "HTML", $"{module.FileName}.html");
if (!File.Exists(fullPath))
return HttpResponse.PlainText($"Cannot find {filename}.", HttpStatusCode._500_InternalServerError);
messages.AppendLine($"{module.Name} (no pref) ⇒ {fullPath}");
}
// Generate PDFs
if (fullPath.EndsWith(".html") && File.Exists(fullPath))
{
var avoidGenerating = (DateTime.UtcNow - startTime).TotalSeconds > 5;
var (pdfFilename, pdfGenerated) = generatePdf(fullPath, avoidGenerating, module.PageRenderTime ?? defaultRenderDelay);
messages.AppendLine($"{module.Name} avoid={avoidGenerating} gen={pdfGenerated} file={pdfFilename}");
if (avoidGenerating && pdfGenerated)
{
notGenerated.Add(Path.GetFileNameWithoutExtension(fullPath));
messages.AppendLine($"{module.Name} added to not-generated list");
}
else if (!avoidGenerating)
{
if (pdfGenerated)
generated++;
fullPath = pdfFilename;
messages.AppendLine($"{module.Name} (no pref) ⇒ {fullPath} ({(pdfGenerated ? "generated" : "from cache")})");
}
}
if (File.Exists(fullPath))
pdfFiles.Add(fullPath);
else
messages.AppendLine($" — {fullPath} does not exist");
}
if (pdfFiles.Count == 0)
return HttpResponse.PlainText($"Error: no matching manuals found.\n\n{messages}", HttpStatusCode._500_InternalServerError);
if (notGenerated.Count > 0)
return HttpResponse.Html($"Looks like you’re asking me to generate a lot of PDF files from HTML. I’ve just generated {generated} and I will need to generate the following {notGenerated.Count} more. Please refresh this page once every minute to incrementally have your PDFs generated. Please do not overload the server with excessive requests for merged PDFs, or this feature will need to be disabled.<ul>{notGenerated.Select(g => $"<li>{g.HtmlEscape()}</li>").JoinString()}</ul><!--\n\n{messages}\n\n-->", HttpStatusCode._202_Accepted);
var list = pdfFiles.JoinString("\n");
using var mem = new MemoryStream(list.ToUtf8());
using var sha1hash = SHA1.Create();
var sha1 = sha1hash.ComputeHash(mem).ToHex();
var pdfPath = Path.Combine(_config.BaseDir, _config.MergedPdfsDir, $"{sha1}.pdf");
if (!File.Exists(pdfPath))
lock (this)
if (!File.Exists(pdfPath))
{
var mergedPdf = new PdfDocument();
foreach (var pdfFile in pdfFiles)
{
lastExaminedPdfFile = pdfFile;
var pdf = PdfReader.Open(Path.Combine(_config.BaseDir, _config.PdfDir, pdfFile), PdfDocumentOpenMode.Import);
var count = pdf.PageCount;
for (var idx = 0; idx < count; idx++)
mergedPdf.AddPage(pdf.Pages[idx]);
}
using var f = File.OpenWrite(pdfPath);
mergedPdf.Save(f);
}
return HttpResponse.Redirect(req.Url.WithPathParent().WithPathOnly($"/MergedPdfs/{sha1}.pdf"));
}
catch (Exception e)
{
var exc = e;
var sb = new StringBuilder();
while (exc != null)
{
sb.AppendLine($"Error processing PDFs:\r\n{e.GetType().FullName}\r\n{e.Message}\r\nPossible culprit: {lastExaminedPdfFile}\r\n\r\n{e.StackTrace}\r\n\r\n");
exc = exc.InnerException;
}
return HttpResponse.PlainText(sb.ToString(), HttpStatusCode._500_InternalServerError);
}
}
}
}