WIP: Mostly done with pdf client, need to add tests.

This commit is contained in:
2026-01-28 15:47:56 -05:00
parent 458b3bd644
commit c82f20bb60
15 changed files with 211 additions and 76 deletions

View File

@@ -105,6 +105,8 @@ let package = Package(
.target(
name: "PdfClient",
dependencies: [
.target(name: "EnvClient"),
.target(name: "FileClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
@@ -117,6 +119,7 @@ let package = Package(
.target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "PdfClient"),
.product(name: "Vapor", package: "vapor"),
]
),
.target(

12
Public/css/htmx.css Normal file
View File

@@ -0,0 +1,12 @@
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}

View File

@@ -2,4 +2,3 @@
@plugin "daisyui" {
themes: all;
}

View File

@@ -8548,10 +8548,6 @@
.italic {
font-style: italic;
}
.lining-nums {
--tw-numeric-figure: lining-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
}
.tabular-nums {
--tw-numeric-spacing: tabular-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);

View File

@@ -0,0 +1,63 @@
// Copied from: https://github.com/dakixr/htmx-download/blob/main/htmx-download.js
htmx.defineExtension('htmx-download', {
onEvent: function(name, evt) {
if (name === 'htmx:beforeRequest') {
// Set the responseType to 'arraybuffer' to handle binary data
evt.detail.xhr.responseType = 'arraybuffer';
}
if (name === 'htmx:beforeSwap') {
const xhr = evt.detail.xhr;
if (xhr.status === 200) {
// Parse headers
const headers = {};
const headerStr = xhr.getAllResponseHeaders();
const headerArr = headerStr.trim().split(/[\r\n]+/);
headerArr.forEach((line) => {
const parts = line.split(": ");
const header = parts.shift().toLowerCase();
const value = parts.join(": ");
headers[header] = value;
});
// Extract filename
let filename = 'downloaded_file.xlsx';
if (headers['content-disposition']) {
const filenameMatch = headers['content-disposition'].match(/filename\*?=(?:UTF-8'')?"?([^;\n"]+)/i);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
}
}
// Determine MIME type
const mimetype = headers['content-type'] || 'application/octet-stream';
// Create Blob
const blob = new Blob([xhr.response], { type: mimetype });
const url = URL.createObjectURL(blob);
// Trigger download
const link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
setTimeout(() => {
URL.revokeObjectURL(url);
link.remove();
}, 100);
} else {
console.warn(`[htmx-download] Unexpected response status: ${xhr.status}`);
}
// Prevent htmx from swapping content
evt.detail.shouldSwap = false;
}
},
});

View File

@@ -117,37 +117,38 @@ extension DuctSizes: Content {}
// FIX: Move
func handlePdf(_ projectID: Project.ID, on request: Request) async throws -> Response {
@Dependency(\.projectClient) var projectClient
return try await projectClient.generatePdf(projectID, request.fileio)
let html = try await projectClient.toHTML(projectID)
let url = "/tmp/\(projectID)"
try await request.fileio.writeFile(.init(string: html.render()), at: "\(url).html")
let process = Process()
let standardInput = Pipe()
let standardOutput = Pipe()
process.standardInput = standardInput
process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: "/bin/pandoc")
process.arguments = [
"\(url).html",
"--pdf-engine=weasyprint",
"--from=html",
"--css=Public/css/pdf.css",
"-o", "\(url).pdf",
]
try process.run()
process.waitUntilExit()
let response = try await request.fileio.asyncStreamFile(at: "\(url).pdf", mediaType: .pdf) { _ in
// Remove files here.
try FileManager.default.removeItem(atPath: "\(url).pdf")
try FileManager.default.removeItem(atPath: "\(url).html")
}
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd(
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
)
return response
// let html = try await projectClient.toHTML(projectID)
// let url = "/tmp/\(projectID)"
// try await request.fileio.writeFile(.init(string: html.render()), at: "\(url).html")
//
// let process = Process()
// let standardInput = Pipe()
// let standardOutput = Pipe()
// process.standardInput = standardInput
// process.standardOutput = standardOutput
// process.executableURL = URL(fileURLWithPath: "/bin/pandoc")
// process.arguments = [
// "\(url).html",
// "--pdf-engine=weasyprint",
// "--from=html",
// "--css=Public/css/pdf.css",
// "-o", "\(url).pdf",
// ]
// try process.run()
// process.waitUntilExit()
//
// let response = try await request.fileio.asyncStreamFile(at: "\(url).pdf", mediaType: .pdf) { _ in
// // Remove files here.
// try FileManager.default.removeItem(atPath: "\(url).pdf")
// try FileManager.default.removeItem(atPath: "\(url).html")
// }
// response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
// response.headers.replaceOrAdd(
// name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
// )
// return response
}
@Sendable

View File

@@ -16,13 +16,19 @@ extension DependencyValues {
@DependencyClient
struct EnvClient: Sendable {
public var env: @Sendable () throws -> EnvVars
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
public init(
@@ -42,6 +48,7 @@ public struct EnvVars: Codable, Equatable, Sendable {
extension EnvClient: DependencyKey {
static let testValue = Self()
static let liveValue = Self(env: {
// Convert default values into a dictionary.
let defaults =

View File

@@ -1,6 +1,9 @@
import Dependencies
import DependenciesMacros
import Elementary
import EnvClient
import FileClient
import Foundation
import ManualDCore
extension DependencyValues {
@@ -14,6 +17,13 @@ extension DependencyValues {
@DependencyClient
public struct PdfClient: Sendable {
public var html: @Sendable (Request) async throws -> (any HTML & Sendable)
public var generatePdf: @Sendable (Project.ID, any HTML & Sendable) async throws -> Response
public func generatePdf(request: Request) async throws -> Response {
let html = try await self.html(request)
return try await self.generatePdf(request.project.id, html)
}
}
extension PdfClient: DependencyKey {
@@ -22,6 +32,33 @@ extension PdfClient: DependencyKey {
public static let liveValue = Self(
html: { request in
request.toHTML()
},
generatePdf: { projectID, html in
@Dependency(\.fileClient) var fileClient
@Dependency(\.env) var env
let envVars = try env()
let baseUrl = "/tmp/\(projectID)"
try await fileClient.writeFile(html.render(), "\(baseUrl).html")
let process = Process()
let standardInput = Pipe()
let standardOutput = Pipe()
process.standardInput = standardInput
process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: envVars.pandocPath)
process.arguments = [
"\(baseUrl).html",
"--pdf-engine=\(envVars.pdfEngine)",
"--from=html",
"--css=Public/css/pdf.css",
"--output=\(baseUrl).pdf",
]
try process.run()
process.waitUntilExit()
return .init(htmlPath: "\(baseUrl).html", pdfPath: "\(baseUrl).pdf")
}
)
}
@@ -40,6 +77,10 @@ extension PdfClient {
public let frictionRate: FrictionRate
public let projectSHR: Double
var totalEquivalentLength: Double {
maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength
}
public init(
project: Project,
rooms: [Room],
@@ -62,6 +103,17 @@ extension PdfClient {
self.projectSHR = projectSHR
}
}
public struct Response: Equatable, Sendable {
public let htmlPath: String
public let pdfPath: String
public init(htmlPath: String, pdfPath: String) {
self.htmlPath = htmlPath
self.pdfPath = pdfPath
}
}
}
#if DEBUG

View File

@@ -63,7 +63,6 @@ struct PdfDocument: HTMLDocument {
}
}
}
div(.class("section")) {
h2 { "Duct Sizes" }
DuctSizesTable(rooms: request.ductSizes.rooms)

View File

@@ -3,6 +3,7 @@ import DependenciesMacros
import Elementary
import ManualDClient
import ManualDCore
import Vapor
extension DependencyValues {
public var projectClient: ProjectClient {
@@ -29,8 +30,10 @@ public struct ProjectClient: Sendable {
public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse
// FIX: Name to something to do with generating a pdf, just experimenting now.
public var toMarkdown: @Sendable (Project.ID) async throws -> String
public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable)
// public var toMarkdown: @Sendable (Project.ID) async throws -> String
// public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable)
public var generatePdf: @Sendable (Project.ID, FileIO) async throws -> Response
}
extension ProjectClient: TestDependencyKey {

View File

@@ -1,5 +1,6 @@
import DatabaseClient
import Dependencies
import FileClient
import Logging
import ManualDClient
import ManualDCore
@@ -11,6 +12,7 @@ extension ProjectClient: DependencyKey {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.pdfClient) var pdfClient
@Dependency(\.fileClient) var fileClient
return .init(
calculateDuctSizes: { projectID in
@@ -35,11 +37,21 @@ extension ProjectClient: DependencyKey {
frictionRate: { projectID in
try await manualD.frictionRate(projectID: projectID)
},
toMarkdown: { projectID in
try await pdfClient.markdown(database.makePdfRequest(projectID))
},
toHTML: { projectID in
try await pdfClient.html(database.makePdfRequest(projectID))
generatePdf: { projectID, fileIO in
let pdfResponse = try await pdfClient.generatePdf(
request: database.makePdfRequest(projectID))
let response = try await fileIO.asyncStreamFile(at: pdfResponse.pdfPath) { _ in
try await fileClient.removeFile(pdfResponse.htmlPath)
try await fileClient.removeFile(pdfResponse.pdfPath)
}
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd(
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
)
return response
}
)
}
@@ -48,31 +60,6 @@ extension ProjectClient: DependencyKey {
extension DatabaseClient {
// fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
// @Dependency(\.manualD) var manualD
//
// guard let project = try await projects.get(projectID) else {
// throw ProjectClientError("Project not found. id: \(projectID)")
// }
// let frictionRateResponse = try await manualD.frictionRate(projectID: projectID)
// guard let frictionRate = frictionRateResponse.frictionRate else {
// throw ProjectClientError("Friction rate not found. id: \(projectID)")
// }
// let (ductSizes, sharedInfo, rooms) = try await calculateDuctSizes(projectID: projectID)
//
// return .init(
// project: project,
// rooms: rooms,
// componentLosses: frictionRateResponse.componentLosses,
// ductSizes: ductSizes,
// equipmentInfo: sharedInfo.equipmentInfo,
// maxSupplyTEL: sharedInfo.maxSupplyLength,
// maxReturnTEL: sharedInfo.maxReturnLenght,
// frictionRate: frictionRate,
// projectSHR: sharedInfo.projectSHR
// )
// }
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD

View File

@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
extension HTMLAttribute.hx {
@Sendable
public static func indicator() -> HTMLAttribute {
indicator(".hx-indicator")
indicator(".htmx-indicator")
}
}

View File

@@ -195,7 +195,9 @@ extension SiteRoute.View.ProjectRoute {
case .pdf:
// FIX: This should return a pdf to download or be wrapped in a
// result view.
return try! await projectClient.toHTML(projectID)
// return try! await projectClient.toHTML(projectID)
// This get's handled elsewhere because it returns a response, not a view.
fatalError()
case .rooms(let route):
return await route.renderView(on: request, projectID: projectID)
}

View File

@@ -24,11 +24,20 @@ struct DuctSizingView: HTML, Sendable {
.attributes(.class("text-error font-bold italic mt-4"))
}
a(
.class("btn btn-primary"),
.href(route: .project(.detail(projectID, .pdf)))
) {
"PDF"
div {
button(
.class("btn btn-primary"),
.hx.get(route: .project(.detail(projectID, .pdf))),
.hx.ext("htmx-download"),
.hx.swap(.none),
.hx.indicator()
) {
span { "PDF" }
Indicator()
}
// div {
// Indicator()
// }
}
}

View File

@@ -50,8 +50,10 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
meta(.content("1024"), .name("og:image:height"))
meta(.content(keywords), .name(.keywords))
script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
script(.src("/js/htmx-download.js")) {}
script(.src("/js/main.js")) {}
link(.rel(.stylesheet), .href("/css/output.css"))
link(.rel(.stylesheet), .href("/css/htmx.css"))
link(
.rel(.icon),
.href("/images/favicon.ico"),