Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
<!--
Suppress warnings similar to the following:
warning NU1507: There are 2 package sources defined in your configuration.
warning CS0436: IgnoresAccessChecksTo redefinition due to InternalsVisibleTo
-->
<NoWarn>$(NoWarn);NU1507</NoWarn>
<NoWarn>$(NoWarn);NU1507;CS0436</NoWarn>
</PropertyGroup>

<PropertyGroup>
Expand Down
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@
<PackageVersion Include="Microsoft.CodeAnalysis.VisualBasic" Version="4.7.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.7.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.7.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.37.1" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="OneOf" Version="3.0.263" />
<PackageVersion Include="OneOf.SourceGenerator" Version="3.0.263" />
<PackageVersion Include="PdfPig" Version="0.1.8" />
<PackageVersion Include="Spectre.Console" Version="0.47.0" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.47.0" />
<PackageVersion Include="Stubble.Core" Version="1.10.8" />
<PackageVersion Include="System.Collections.Immutable" Version="7.0.0" />
Expand All @@ -38,7 +40,6 @@
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="Magick.NET-Q16-AnyCPU" Version="13.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
<PackageVersion Include="Microsoft.Playwright" Version="1.37.1" />
<PackageVersion Include="NuGet.Frameworks" Version="6.8.0-rc.122" Condition="'$(TargetFramework)' != 'net8.0'" />
<PackageVersion Include="NuGet.Frameworks" Version="6.8.0-rc.122" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageVersion Include="Verify.DiffPlex" Version="2.2.1" />
Expand Down
11 changes: 11 additions & 0 deletions src/Docfx.App/Docfx.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
<InternalsVisibleTo Include="docfx" />
<InternalsVisibleTo Include="docfx.Tests" />
</ItemGroup>

<ItemGroup>
<InternalsAssemblyName Include="UglyToad.PdfPig" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="IgnoresAccessChecksToGenerator" PrivateAssets="All" />
<PackageReference Include="Microsoft.Playwright" />
<PackageReference Include="PdfPig" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
Expand Down
209 changes: 209 additions & 0 deletions src/Docfx.App/PdfBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using System.Net.Http.Json;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Playwright;
using Spectre.Console;

using UglyToad.PdfPig;
using UglyToad.PdfPig.Actions;
using UglyToad.PdfPig.Outline;
using UglyToad.PdfPig.Outline.Destinations;
using UglyToad.PdfPig.Writer;

namespace Docfx.Pdf;

static class PdfBuilder
{
class Outline
{
public string name { get; init; } = "";

public string? href { get; init; }

public Outline[]? items { get; init; }
}

public static async Task CreatePdf(Uri outlineUrl, CancellationToken cancellationToken = default)
{
using var http = new HttpClient();

var outline = await AnsiConsole.Status().StartAsync(
$"Downloading {outlineUrl}...",
c => http.GetFromJsonAsync<Outline>(outlineUrl, cancellationToken));

if (outline is null)
return;

AnsiConsole.Status().Start(
"Installing Chromium...",
c => Program.Main(new[] { "install", "chromium" }));

using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync();

var tempDirectory = Path.Combine(Path.GetTempPath(), ".docfx", "pdf", "pages");
Directory.CreateDirectory(tempDirectory);

var pages = GetPages(outline).ToArray();
if (pages.Length == 0)
{
// TODO: Warn
return;
}

var pagesByNode = pages.ToDictionary(p => p.node);
var pagesByUrl = new Dictionary<Uri, List<(Outline node, NamedDestinations namedDests)>>();
var pageNumbers = new Dictionary<Outline, int>();
var nextPageNumbers = new Dictionary<Outline, int>();
var nextPageNumber = 1;
var margin = "0.4in";

await AnsiConsole.Progress().Columns(new SpinnerColumn(), new TaskDescriptionColumn { Alignment = Justify.Left }).StartAsync(async c =>
{
await Parallel.ForEachAsync(pages, async (item, CancellationToken) =>
{
var task = c.AddTask(item.url.ToString());
var page = await browser.NewPageAsync();
await page.GotoAsync(item.url.ToString());
var bytes = await page.PdfAsync(new() { Margin = new() { Bottom = margin, Top = margin, Left = margin, Right = margin } });
File.WriteAllBytes(item.path, bytes);
task.Value = task.MaxValue;
});
});

AnsiConsole.Status().Start("Creating PDF...", _ => MergePdf());

IEnumerable<(string path, Uri url, Outline node)> GetPages(Outline outline)
{
if (!string.IsNullOrEmpty(outline.href))
{
var url = new Uri(outlineUrl, outline.href);
if (url.Host == outlineUrl.Host)
{
var id = Convert.ToHexString(SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(url.ToString())));
yield return (Path.Combine(tempDirectory, $"{id}.pdf"), url, outline);
}
}

if (outline.items != null)
foreach (var item in outline.items)
foreach (var url in GetPages(item))
yield return url;
}

void MergePdf()
{
using var output = File.Create("output.pdf");
using var builder = new PdfDocumentBuilder(output);

builder.DocumentInformation = new()
{
Title = "",
Producer = $"docfx ({typeof(PdfBuilder).Assembly.GetCustomAttribute<AssemblyVersionAttribute>()?.Version})",
};

// Calculate page number
var pageNumber = 1;
foreach (var (path, url, node) in pages)
{
using var document = PdfDocument.Open(path);

var key = CleanUrl(url);
if (!pagesByUrl.TryGetValue(key, out var dests))
pagesByUrl[key] = dests = new();
dests.Add((node, document.Structure.Catalog.NamedDestinations));

pageNumbers[node] = pageNumber;
pageNumber += document.NumberOfPages;
nextPageNumbers[node] = pageNumber;
}

// Copy pages
foreach (var (path, url, node) in pages)
{
using var document = PdfDocument.Open(path);
for (var i = 1; i <= document.NumberOfPages; i++)
builder.AddPage(document, i, CopyLink);
}

builder.Bookmarks = new(CreateBookmarks(outline.items).ToArray());
}

PdfAction CopyLink(PdfAction action)
{
return action switch
{
GoToAction link => new GoToAction(new(link.Destination.PageNumber, link.Destination.Type, link.Destination.Coordinates)),
UriAction url => HandleUriAction(url),
_ => action,
};

PdfAction HandleUriAction(UriAction url)
{
if (!Uri.TryCreate(url.Uri, UriKind.Absolute, out var uri) || !pagesByUrl.TryGetValue(CleanUrl(uri), out var pages))
return url;

if (!string.IsNullOrEmpty(uri.Fragment) && uri.Fragment.Length > 1)
{
var name = uri.Fragment.Substring(1);
foreach (var (node, namedDests) in pages)
{
if (namedDests.TryGet(name, out var dest))
{
AnsiConsole.MarkupLine($"[green]Resolve succeed: {name}[/]");
return new GoToAction(new(1, dest.Type, dest.Coordinates));
}
}

AnsiConsole.MarkupLine($"[yellow]Failed to resolve named dest: {name}[/]");
}

return new GoToAction(new(pageNumbers[pages[0].node], ExplicitDestinationType.FitHorizontally, ExplicitDestinationCoordinates.Empty));
}
}

static Uri CleanUrl(Uri url)
{
return new UriBuilder(url) { Query = null, Fragment = null }.Uri;
}

IEnumerable<BookmarkNode> CreateBookmarks(Outline[]? items, int level = 0)
{
if (items is null)
yield break;

foreach (var item in items)
{
if (string.IsNullOrEmpty(item.href))
{
yield return new DocumentBookmarkNode(
item.name, level,
new(nextPageNumber, ExplicitDestinationType.FitHorizontally, ExplicitDestinationCoordinates.Empty),
CreateBookmarks(item.items, level + 1).ToArray());
continue;
}

if (!pagesByNode.TryGetValue(item, out var page))
{
yield return new UriBookmarkNode(
item.name, level,
new Uri(outlineUrl, item.href).ToString(),
CreateBookmarks(item.items, level + 1).ToArray());
continue;
}

nextPageNumber = nextPageNumbers[item];
yield return new DocumentBookmarkNode(
item.name, level,
new(pageNumbers[item], ExplicitDestinationType.FitHorizontally, ExplicitDestinationCoordinates.Empty),
CreateBookmarks(item.items, level + 1).ToArray());
}
}
}
}
8 changes: 8 additions & 0 deletions src/docfx/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
"environmentVariables": {
}
},
// Run `docfx pdf` command and launch browser.
"docfx pdf": {
"commandName": "Project",
"commandLineArgs": "pdf ../../samples/seed/docfx.json",
"workingDirectory": ".",
"environmentVariables": {
}
},
// Run `docfx` command.
"docfx": {
"commandName": "Project",
Expand Down
6 changes: 6 additions & 0 deletions templates/modern/src/dotnet.scss
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ body[data-yaml-mime="ManagedReference"] article, body[data-yaml-mime="ApiPage"]
font-size: 1.2rem;
}

@media print {
.header-action {
display: none;
}
}

td.term {
font-weight: 600;
}
Expand Down
2 changes: 1 addition & 1 deletion test/docfx.Snapshot.Tests/SamplesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ private static readonly (int width, int height, string theme, bool fullPage)[] s

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

Expand Down