Skip to content

Commit 73b2c2b

Browse files
committed
feat: PDF builder using Chromium
1 parent 833716d commit 73b2c2b

File tree

6 files changed

+237
-2
lines changed

6 files changed

+237
-2
lines changed

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
<PackageVersion Include="Microsoft.CodeAnalysis.VisualBasic" Version="4.7.0" />
2323
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.7.0" />
2424
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.7.0" />
25+
<PackageVersion Include="Microsoft.Playwright" Version="1.37.1" />
2526
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" />
2627
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
2728
<PackageVersion Include="OneOf" Version="3.0.263" />
2829
<PackageVersion Include="OneOf.SourceGenerator" Version="3.0.263" />
2930
<PackageVersion Include="PdfPig" Version="0.1.8" />
31+
<PackageVersion Include="Spectre.Console" Version="0.47.0" />
3032
<PackageVersion Include="Spectre.Console.Cli" Version="0.47.0" />
3133
<PackageVersion Include="Stubble.Core" Version="1.10.8" />
3234
<PackageVersion Include="System.Collections.Immutable" Version="7.0.0" />
@@ -38,7 +40,6 @@
3840
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
3941
<PackageVersion Include="Magick.NET-Q16-AnyCPU" Version="13.4.0" />
4042
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
41-
<PackageVersion Include="Microsoft.Playwright" Version="1.37.1" />
4243
<PackageVersion Include="NuGet.Frameworks" Version="6.8.0-rc.122" Condition="'$(TargetFramework)' != 'net8.0'" />
4344
<PackageVersion Include="NuGet.Frameworks" Version="6.8.0-rc.122" Condition="'$(TargetFramework)' == 'net8.0'" />
4445
<PackageVersion Include="Verify.DiffPlex" Version="2.2.1" />

src/Docfx.App/Docfx.App.csproj

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@
1313
<InternalsVisibleTo Include="docfx" />
1414
<InternalsVisibleTo Include="docfx.Tests" />
1515
</ItemGroup>
16+
17+
<ItemGroup>
18+
<InternalsAssemblyName Include="UglyToad.PdfPig" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<PackageReference Include="IgnoresAccessChecksToGenerator" PrivateAssets="All" />
23+
<PackageReference Include="Microsoft.Playwright" />
24+
<PackageReference Include="PdfPig" />
25+
<PackageReference Include="Spectre.Console" />
26+
</ItemGroup>
1627

1728
<ItemGroup>
1829
<FrameworkReference Include="Microsoft.AspNetCore.App" />

src/Docfx.App/PdfBuilder.cs

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#nullable enable
5+
6+
using System.Net.Http.Json;
7+
using System.Reflection;
8+
using System.Security.Cryptography;
9+
using System.Text;
10+
using Microsoft.Playwright;
11+
using Spectre.Console;
12+
13+
using UglyToad.PdfPig;
14+
using UglyToad.PdfPig.Actions;
15+
using UglyToad.PdfPig.Outline;
16+
using UglyToad.PdfPig.Outline.Destinations;
17+
using UglyToad.PdfPig.Writer;
18+
19+
namespace Docfx.Pdf;
20+
21+
static class PdfBuilder
22+
{
23+
class Outline
24+
{
25+
public string name { get; init; } = "";
26+
27+
public string? href { get; init; }
28+
29+
public Outline[]? items { get; init; }
30+
}
31+
32+
public static async Task CreatePdf(Uri outlineUrl, CancellationToken cancellationToken = default)
33+
{
34+
using var http = new HttpClient();
35+
36+
var outline = await AnsiConsole.Status().StartAsync(
37+
$"Downloading {outlineUrl}...",
38+
c => http.GetFromJsonAsync<Outline>(outlineUrl, cancellationToken));
39+
40+
if (outline is null)
41+
return;
42+
43+
AnsiConsole.Status().Start(
44+
"Installing Chromium...",
45+
c => Program.Main(new[] { "install", "chromium" }));
46+
47+
using var playwright = await Playwright.CreateAsync();
48+
var browser = await playwright.Chromium.LaunchAsync();
49+
50+
var tempDirectory = Path.Combine(Path.GetTempPath(), ".docfx", "pdf", "pages");
51+
Directory.CreateDirectory(tempDirectory);
52+
53+
var pages = GetPages(outline).ToArray();
54+
if (pages.Length == 0)
55+
{
56+
// TODO: Warn
57+
return;
58+
}
59+
60+
var pagesByNode = pages.ToDictionary(p => p.node);
61+
var pagesByUrl = new Dictionary<Uri, List<(Outline node, NamedDestinations namedDests)>>();
62+
var pageNumbers = new Dictionary<Outline, int>();
63+
var nextPageNumbers = new Dictionary<Outline, int>();
64+
var nextPageNumber = 1;
65+
var margin = "0.4in";
66+
67+
await AnsiConsole.Progress().Columns(new SpinnerColumn(), new TaskDescriptionColumn { Alignment = Justify.Left }).StartAsync(async c =>
68+
{
69+
await Parallel.ForEachAsync(pages, async (item, CancellationToken) =>
70+
{
71+
var task = c.AddTask(item.url.ToString());
72+
var page = await browser.NewPageAsync();
73+
await page.GotoAsync(item.url.ToString());
74+
var bytes = await page.PdfAsync(new() { Margin = new() { Bottom = margin, Top = margin, Left = margin, Right = margin } });
75+
File.WriteAllBytes(item.path, bytes);
76+
task.Value = task.MaxValue;
77+
});
78+
});
79+
80+
AnsiConsole.Status().Start("Creating PDF...", _ => MergePdf());
81+
82+
IEnumerable<(string path, Uri url, Outline node)> GetPages(Outline outline)
83+
{
84+
if (!string.IsNullOrEmpty(outline.href))
85+
{
86+
var url = new Uri(outlineUrl, outline.href);
87+
if (url.Host == outlineUrl.Host)
88+
{
89+
var id = Convert.ToHexString(SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(url.ToString())));
90+
yield return (Path.Combine(tempDirectory, $"{id}.pdf"), url, outline);
91+
}
92+
}
93+
94+
if (outline.items != null)
95+
foreach (var item in outline.items)
96+
foreach (var url in GetPages(item))
97+
yield return url;
98+
}
99+
100+
void MergePdf()
101+
{
102+
using var output = File.Create("output.pdf");
103+
using var builder = new PdfDocumentBuilder(output);
104+
105+
builder.DocumentInformation = new()
106+
{
107+
Title = "",
108+
Producer = $"docfx ({typeof(PdfBuilder).Assembly.GetCustomAttribute<AssemblyVersionAttribute>()?.Version})",
109+
};
110+
111+
// Calculate page number
112+
var pageNumber = 1;
113+
foreach (var (path, url, node) in pages)
114+
{
115+
using var document = PdfDocument.Open(path);
116+
117+
var key = CleanUrl(url);
118+
if (!pagesByUrl.TryGetValue(key, out var dests))
119+
pagesByUrl[key] = dests = new();
120+
dests.Add((node, document.Structure.Catalog.NamedDestinations));
121+
122+
pageNumbers[node] = pageNumber;
123+
pageNumber += document.NumberOfPages;
124+
nextPageNumbers[node] = pageNumber;
125+
}
126+
127+
// Copy pages
128+
foreach (var (path, url, node) in pages)
129+
{
130+
using var document = PdfDocument.Open(path);
131+
for (var i = 1; i <= document.NumberOfPages; i++)
132+
builder.AddPage(document, i, CopyLink);
133+
}
134+
135+
builder.Bookmarks = new(CreateBookmarks(outline.items).ToArray());
136+
}
137+
138+
PdfAction CopyLink(PdfAction action)
139+
{
140+
return action switch
141+
{
142+
GoToAction link => new GoToAction(new(link.Destination.PageNumber, link.Destination.Type, link.Destination.Coordinates)),
143+
UriAction url => HandleUriAction(url),
144+
_ => action,
145+
};
146+
147+
PdfAction HandleUriAction(UriAction url)
148+
{
149+
if (!Uri.TryCreate(url.Uri, UriKind.Absolute, out var uri) || !pagesByUrl.TryGetValue(CleanUrl(uri), out var pages))
150+
return url;
151+
152+
if (!string.IsNullOrEmpty(uri.Fragment) && uri.Fragment.Length > 1)
153+
{
154+
var name = uri.Fragment.Substring(1);
155+
foreach (var (node, namedDests) in pages)
156+
{
157+
if (namedDests.TryGet(name, out var dest))
158+
{
159+
AnsiConsole.MarkupLine($"[green]Resolve succeed: {name}[/]");
160+
return new GoToAction(new(1, dest.Type, dest.Coordinates));
161+
}
162+
}
163+
164+
AnsiConsole.MarkupLine($"[yellow]Failed to resolve named dest: {name}[/]");
165+
}
166+
167+
return new GoToAction(new(pageNumbers[pages[0].node], ExplicitDestinationType.FitHorizontally, ExplicitDestinationCoordinates.Empty));
168+
}
169+
}
170+
171+
static Uri CleanUrl(Uri url)
172+
{
173+
return new UriBuilder(url) { Query = null, Fragment = null }.Uri;
174+
}
175+
176+
IEnumerable<BookmarkNode> CreateBookmarks(Outline[]? items, int level = 0)
177+
{
178+
if (items is null)
179+
yield break;
180+
181+
foreach (var item in items)
182+
{
183+
if (string.IsNullOrEmpty(item.href))
184+
{
185+
yield return new DocumentBookmarkNode(
186+
item.name, level,
187+
new(nextPageNumber, ExplicitDestinationType.FitHorizontally, ExplicitDestinationCoordinates.Empty),
188+
CreateBookmarks(item.items, level + 1).ToArray());
189+
continue;
190+
}
191+
192+
if (!pagesByNode.TryGetValue(item, out var page))
193+
{
194+
yield return new UriBookmarkNode(
195+
item.name, level,
196+
new Uri(outlineUrl, item.href).ToString(),
197+
CreateBookmarks(item.items, level + 1).ToArray());
198+
continue;
199+
}
200+
201+
nextPageNumber = nextPageNumbers[item];
202+
yield return new DocumentBookmarkNode(
203+
item.name, level,
204+
new(pageNumbers[item], ExplicitDestinationType.FitHorizontally, ExplicitDestinationCoordinates.Empty),
205+
CreateBookmarks(item.items, level + 1).ToArray());
206+
}
207+
}
208+
}
209+
}

src/docfx/Properties/launchSettings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
"environmentVariables": {
2727
}
2828
},
29+
// Run `docfx pdf` command and launch browser.
30+
"docfx pdf": {
31+
"commandName": "Project",
32+
"commandLineArgs": "pdf ../../samples/seed/docfx.json",
33+
"workingDirectory": ".",
34+
"environmentVariables": {
35+
}
36+
},
2937
// Run `docfx` command.
3038
"docfx": {
3139
"commandName": "Project",

templates/modern/src/dotnet.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ body[data-yaml-mime="ManagedReference"] article, body[data-yaml-mime="ApiPage"]
104104
font-size: 1.2rem;
105105
}
106106

107+
@media print {
108+
.header-action {
109+
display: none;
110+
}
111+
}
112+
107113
td.term {
108114
font-weight: 600;
109115
}

test/docfx.Snapshot.Tests/SamplesTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ private static readonly (int width, int height, string theme, bool fullPage)[] s
5252

5353
static SamplesTest()
5454
{
55-
Microsoft.Playwright.Program.Main(new[] { "install" });
55+
Microsoft.Playwright.Program.Main(new[] { "install", "chromium" });
5656
Process.Start("dotnet", $"build \"{s_samplesDir}/seed/dotnet/assembly/BuildFromAssembly.csproj\"").WaitForExit();
5757
}
5858

0 commit comments

Comments
 (0)