diff --git a/.gitignore b/.gitignore index 86312e5..d90fff2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ DerivedData/ node_modules/ tailwindcss .envrc +*.pdf diff --git a/Public/css/pdf.css b/Public/css/pdf.css new file mode 100644 index 0000000..4e331be --- /dev/null +++ b/Public/css/pdf.css @@ -0,0 +1,103 @@ +@media print { + body { + -webkit-print-color-adjust: exact; + color-adjust: exact; + print-color-adjust: exact; + } + table td, table th { + -webkit-print-color-adjust: exact; + } +} +table { + border-collapse: collapse; + max-width: 100%; + margin: 10px auto; + border: none !important; + border-style: none; +} +th, td { + padding: 10px; + border: none; + border-style: none; +} +.table-bordered { + border: 1px solid #ccc; +} +.table-bordered th, td { + border: 1px solid #ccc; +} +.table-bordered tr:nth-child(even) { + background-color: #f2f2f2; +} +.w-full { + width: 100%; +} +.w-half { + width: 50%; +} +.table-footer { + background-color: #75af4c; + color: white; + font-weight: bold; +} +.bg-green { + background-color: #4CAF50; + color: white; +} +.heating { + color: red; +} +.coolingTotal { + color: blue; +} +.coolingSensible { + color: cyan; +} +.justify-end { + text-align: end; +} +.flex { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px; +} +.flex table { + width: 50%; + margin: 0; + flex: 1 1 calc(50% - 10px); +} +.container { + display: flex; + width: 100%; + gap: 10px; +} +.table-container { + flex: 1; + min-width: 0; +} +.table-container table { + width: 100%; + border-collapse: collapse; +} +.customerTable { + width: 50%; +} +.section { + padding: 10px; +} +.label { + font-weight: bold; +} +.error { + color: red; + font-weight: bold; +} +.effectiveLengthGroupTable, .effectiveLengthGroupHeader { + background-color: white; + color: black; + font-weight: bold; +} +.headline { + padding: 10px 0; +} diff --git a/Sources/PdfClient/Request+html.swift b/Sources/PdfClient/Request+html.swift index e8f803a..afd2dba 100644 --- a/Sources/PdfClient/Request+html.swift +++ b/Sources/PdfClient/Request+html.swift @@ -15,106 +15,7 @@ struct PdfDocument: HTMLDocument { let request: PdfClient.Request var head: some HTML { - style { - """ - @media print { - body { - -webkit-print-color-adjust: exact; - color-adjust: exact; - print-color-adjust: exact; - } - table td, table th { - -webkit-print-color-adjust: exact; - } - } - table { - max-width: 100%; - border-collapse: collapse; - margin: 10px auto; - border: 1px solid #ccc; - } - th, td { - border: 1px solid #ccc; - padding: 10px; - } - tr:nth-child(even) { - background-color: #f2f2f2; - } - .w-full { - width: 100%; - } - .w-half { - width: 50%; - } - .table-footer { - background-color: #75af4c; - color: white; - font-weight: bold; - } - .bg-green { - background-color: #4CAF50; - color: white; - } - .heating { - color: red; - } - .coolingTotal { - color: blue; - } - .coolingSensible { - color: cyan; - } - .justify-end { - text-align: end; - } - .flex { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 10px; - } - .flex table { - border: 1px solid #ccc; - width: 50%; - margin: 0; - flex: 1 1 calc(50% - 10px); - } - .container { - display: flex; - width: 100%; - gap: 10px; - } - .table-container { - flex: 1; - min-width: 0; - } - .table-container table { - width: 100%; - border-collapse: collapse; - } - .customerTable { - width: 50%; - } - .section { - padding: 10px; - } - .label { - font-weight: bold; - } - .error { - color: red; - font-weight: bold; - } - .effectiveLengthGroupTable, .effectiveLengthGroupHeader { - background-color: white; - color: black; - font-weight: bold; - } - .headline { - padding: 10px 0; - } - """ - } + link(.rel(.stylesheet), .href("/css/pdf.css")) } var body: some HTML { @@ -124,24 +25,7 @@ struct PdfDocument: HTMLDocument { h2 { "Project" } div(.class("flex")) { - table(.class("table customer-table")) { - tbody { - tr { - td { "Name" } - td { request.project.name } - } - tr { - td { "Street Address" } - td { - p { - request.project.streetAddress - br() - request.project.cityStateZipString - } - } - } - } - } + ProjectTable(project: request.project) // HACK: table {} } @@ -156,7 +40,6 @@ struct PdfDocument: HTMLDocument { div(.class("table-container")) { EquipmentTable(title: "Equipment", equipmentInfo: request.equipmentInfo) } - // .attributes(.style("height: 140px;")) div(.class("table-container")) { FrictionRateTable( title: "Friction Rate", @@ -222,310 +105,4 @@ struct PdfDocument: HTMLDocument { } - struct EffectiveLengthsTable: HTML, Sendable { - let effectiveLengths: [EffectiveLength] - - var body: some HTML { - table { - thead { - tr(.class("bg-green")) { - th { "Name" } - th { "Type" } - th { "Straight Lengths" } - th { "Groups" } - th { "Total" } - } - } - tbody { - for row in effectiveLengths { - tr { - td { row.name } - td { row.type.rawValue } - td { - ul { - for length in row.straightLengths { - li { length.string() } - } - } - } - td { - EffectiveLengthGroupTable(groups: row.groups) - .attributes(.class("w-full")) - } - td { row.totalEquivalentLength.string(digits: 0) } - } - } - } - } - } - - } - - struct EffectiveLengthGroupTable: HTML, Sendable { - let groups: [EffectiveLength.Group] - - var body: some HTML { - table { - thead { - tr(.class("effectiveLengthGroupHeader")) { - th { "Name" } - th { "Length" } - th { "Quantity" } - th { "Total" } - } - } - tbody { - for row in groups { - tr { - td { "\(row.group)-\(row.letter)" } - td { row.value.string(digits: 0) } - td { row.quantity.string() } - td { (row.value * Double(row.quantity)).string(digits: 0) } - } - } - } - } - } - } - - struct RoomsTable: HTML, Sendable { - let rooms: [Room] - let projectSHR: Double - - var body: some HTML { - table { - thead { - tr(.class("bg-green")) { - th { "Name" } - th { "Heating BTU" } - th { "Cooling Total BTU" } - th { "Cooling Sensible BTU" } - th { "Register Count" } - } - } - tbody { - for room in rooms { - tr { - td { room.name } - td { room.heatingLoad.string(digits: 0) } - td { room.coolingTotal.string(digits: 0) } - td { - (room.coolingSensible - ?? (room.coolingTotal * projectSHR)).string(digits: 0) - } - td { room.registerCount.string() } - } - } - // Totals - // tr(.class("table-footer")) { - tr { - td(.class("label")) { "Totals" } - td(.class("heating label")) { - rooms.totalHeatingLoad.string(digits: 0) - } - td(.class("coolingTotal label")) { - rooms.totalCoolingLoad.string(digits: 0) - } - td(.class("coolingSensible label")) { - rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0) - } - td {} - } - } - } - } - } - - struct RegisterDetailTable: HTML, Sendable { - let rooms: [DuctSizes.RoomContainer] - - var body: some HTML { - table { - thead { - tr(.class("bg-green")) { - th { "Name" } - th { "Heating BTU" } - th { "Cooling BTU" } - th { "Heating CFM" } - th { "Cooling CFM" } - th { "Design CFM" } - } - } - tbody { - for row in rooms { - tr { - td { row.roomName } - td { row.heatingLoad.string(digits: 0) } - td { row.coolingLoad.string(digits: 0) } - td { row.heatingCFM.string(digits: 0) } - td { row.coolingCFM.string(digits: 0) } - td { row.designCFM.value.string(digits: 0) } - } - } - } - } - } - } - - struct TrunkTable: HTML, Sendable { - public let sizes: DuctSizes - public let type: TrunkSize.TrunkType - - var trunks: [DuctSizes.TrunkContainer] { - sizes.trunks.filter { $0.type == type } - } - - var body: some HTML { - table { - thead(.class("bg-green")) { - tr { - th { "Name" } - th { "Dsn CFM" } - th { "Round Size" } - th { "Velocity" } - th { "Final Size" } - th { "Flex Size" } - th { "Height" } - th { "Width" } - } - } - tbody { - for row in trunks { - tr { - td { row.name ?? "" } - td { row.designCFM.value.string(digits: 0) } - td { row.ductSize.roundSize.string() } - td { row.velocity.string() } - td { row.finalSize.string() } - td { row.flexSize.string() } - td { row.ductSize.height?.string() ?? "" } - td { row.width?.string() ?? "" } - } - } - } - } - } - } - - struct DuctSizesTable: HTML, Sendable { - let rooms: [DuctSizes.RoomContainer] - - var body: some HTML { - table { - thead { - tr(.class("bg-green")) { - th { "Name" } - th { "Dsn CFM" } - th { "Round Size" } - th { "Velocity" } - th { "Final Size" } - th { "Flex Size" } - th { "Height" } - th { "Width" } - } - } - tbody { - for row in rooms { - tr { - td { row.roomName } - td { row.designCFM.value.string(digits: 0) } - td { row.roundSize.string() } - td { row.velocity.string() } - td { row.flexSize.string() } - td { row.finalSize.string() } - td { row.ductSize.height?.string() ?? "" } - td { row.width?.string() ?? "" } - } - } - } - } - } - } - - struct EquipmentTable: HTML, Sendable { - let title: String? - let equipmentInfo: EquipmentInfo - - init(title: String? = nil, equipmentInfo: EquipmentInfo) { - self.title = title - self.equipmentInfo = equipmentInfo - } - - var body: some HTML { - - table { - thead { - tr(.class("bg-green")) { - th { title ?? "" } - th(.class("justify-end")) { "Value" } - } - } - tbody { - tr { - td { "Static Pressure" } - td(.class("justify-end")) { equipmentInfo.staticPressure.string() } - } - tr { - td { "Heating CFM" } - td(.class("justify-end")) { equipmentInfo.heatingCFM.string() } - } - tr { - td { "Cooling CFM" } - td(.class("justify-end")) { equipmentInfo.coolingCFM.string() } - } - } - } - } - } - - struct FrictionRateTable: HTML, Sendable { - let title: String? - let componentLosses: [ComponentPressureLoss] - let frictionRate: FrictionRate - let totalEquivalentLength: Double - let displayTotals: Bool - - var sortedLosses: [ComponentPressureLoss] { - componentLosses.sorted { $0.value > $1.value } - } - - var body: some HTML { - table { - thead { - tr(.class("bg-green")) { - th { title ?? "" } - th(.class("justify-end")) { "Value" } - } - } - tbody { - for row in sortedLosses { - tr { - td { row.name } - td(.class("justify-end")) { row.value.string() } - } - } - if displayTotals { - tr { - td(.class("label justify-end")) { "Available Static Pressure" } - td(.class("justify-end")) { frictionRate.availableStaticPressure.string() } - } - tr { - td(.class("label justify-end")) { "Total Equivalent Length" } - td(.class("justify-end")) { totalEquivalentLength.string() } - } - tr { - td(.class("label justify-end")) { "Friction Rate Design Value" } - td(.class("justify-end")) { frictionRate.value.string() } - } - } - } - } - } - } -} - -extension Project { - var cityStateZipString: String { - return "\(city), \(state) \(zipCode)" - } } diff --git a/Sources/PdfClient/Views/DuctSizeTable.swift b/Sources/PdfClient/Views/DuctSizeTable.swift new file mode 100644 index 0000000..070581a --- /dev/null +++ b/Sources/PdfClient/Views/DuctSizeTable.swift @@ -0,0 +1,37 @@ +import Elementary +import ManualDCore + +struct DuctSizesTable: HTML, Sendable { + let rooms: [DuctSizes.RoomContainer] + + var body: some HTML { + table { + thead { + tr(.class("bg-green")) { + th { "Name" } + th { "Dsn CFM" } + th { "Round Size" } + th { "Velocity" } + th { "Final Size" } + th { "Flex Size" } + th { "Height" } + th { "Width" } + } + } + tbody { + for row in rooms { + tr { + td { row.roomName } + td { row.designCFM.value.string(digits: 0) } + td { row.roundSize.string() } + td { row.velocity.string() } + td { row.flexSize.string() } + td { row.finalSize.string() } + td { row.ductSize.height?.string() ?? "" } + td { row.width?.string() ?? "" } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/EquipmentTable.swift b/Sources/PdfClient/Views/EquipmentTable.swift new file mode 100644 index 0000000..74f3cd0 --- /dev/null +++ b/Sources/PdfClient/Views/EquipmentTable.swift @@ -0,0 +1,38 @@ +import Elementary +import ManualDCore + +struct EquipmentTable: HTML, Sendable { + let title: String? + let equipmentInfo: EquipmentInfo + + init(title: String? = nil, equipmentInfo: EquipmentInfo) { + self.title = title + self.equipmentInfo = equipmentInfo + } + + var body: some HTML { + + table { + thead { + tr(.class("bg-green")) { + th { title ?? "" } + th(.class("justify-end")) { "Value" } + } + } + tbody { + tr { + td { "Static Pressure" } + td(.class("justify-end")) { equipmentInfo.staticPressure.string() } + } + tr { + td { "Heating CFM" } + td(.class("justify-end")) { equipmentInfo.heatingCFM.string() } + } + tr { + td { "Cooling CFM" } + td(.class("justify-end")) { equipmentInfo.coolingCFM.string() } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/EquivalentLengthTable.swift b/Sources/PdfClient/Views/EquivalentLengthTable.swift new file mode 100644 index 0000000..a80de05 --- /dev/null +++ b/Sources/PdfClient/Views/EquivalentLengthTable.swift @@ -0,0 +1,68 @@ +import Elementary +import ManualDCore + +struct EffectiveLengthsTable: HTML, Sendable { + let effectiveLengths: [EffectiveLength] + + var body: some HTML { + table { + thead { + tr(.class("bg-green")) { + th { "Name" } + th { "Type" } + th { "Straight Lengths" } + th { "Groups" } + th { "Total" } + } + } + tbody { + for row in effectiveLengths { + tr { + td { row.name } + td { row.type.rawValue } + td { + ul { + for length in row.straightLengths { + li { length.string() } + } + } + } + td { + EffectiveLengthGroupTable(groups: row.groups) + .attributes(.class("w-full")) + } + td { row.totalEquivalentLength.string(digits: 0) } + } + } + } + } + } + +} + +struct EffectiveLengthGroupTable: HTML, Sendable { + let groups: [EffectiveLength.Group] + + var body: some HTML { + table { + thead { + tr(.class("effectiveLengthGroupHeader")) { + th { "Name" } + th { "Length" } + th { "Quantity" } + th { "Total" } + } + } + tbody { + for row in groups { + tr { + td { "\(row.group)-\(row.letter)" } + td { row.value.string(digits: 0) } + td { row.quantity.string() } + td { (row.value * Double(row.quantity)).string(digits: 0) } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/FrictionRateTable.swift b/Sources/PdfClient/Views/FrictionRateTable.swift new file mode 100644 index 0000000..0739de7 --- /dev/null +++ b/Sources/PdfClient/Views/FrictionRateTable.swift @@ -0,0 +1,47 @@ +import Elementary +import ManualDCore + +struct FrictionRateTable: HTML, Sendable { + let title: String? + let componentLosses: [ComponentPressureLoss] + let frictionRate: FrictionRate + let totalEquivalentLength: Double + let displayTotals: Bool + + var sortedLosses: [ComponentPressureLoss] { + componentLosses.sorted { $0.value > $1.value } + } + + var body: some HTML { + table { + thead { + tr(.class("bg-green")) { + th { title ?? "" } + th(.class("justify-end")) { "Value" } + } + } + tbody { + for row in sortedLosses { + tr { + td { row.name } + td(.class("justify-end")) { row.value.string() } + } + } + if displayTotals { + tr { + td(.class("label justify-end")) { "Available Static Pressure" } + td(.class("justify-end")) { frictionRate.availableStaticPressure.string() } + } + tr { + td(.class("label justify-end")) { "Total Equivalent Length" } + td(.class("justify-end")) { totalEquivalentLength.string() } + } + tr { + td(.class("label justify-end")) { "Friction Rate Design Value" } + td(.class("justify-end")) { frictionRate.value.string() } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/ProjectTable.swift b/Sources/PdfClient/Views/ProjectTable.swift new file mode 100644 index 0000000..d25a10e --- /dev/null +++ b/Sources/PdfClient/Views/ProjectTable.swift @@ -0,0 +1,33 @@ +import Elementary +import ManualDCore + +struct ProjectTable: HTML, Sendable { + let project: Project + + var body: some HTML { + table { + tbody { + tr { + td(.class("label")) { "Name" } + td { project.name } + } + tr { + td(.class("label")) { "Address" } + td { + p { + project.streetAddress + br() + project.cityStateZipString + } + } + } + } + } + } +} + +extension Project { + var cityStateZipString: String { + return "\(city), \(state) \(zipCode)" + } +} diff --git a/Sources/PdfClient/Views/RegisterTable.swift b/Sources/PdfClient/Views/RegisterTable.swift new file mode 100644 index 0000000..41f134a --- /dev/null +++ b/Sources/PdfClient/Views/RegisterTable.swift @@ -0,0 +1,33 @@ +import Elementary +import ManualDCore + +struct RegisterDetailTable: HTML, Sendable { + let rooms: [DuctSizes.RoomContainer] + + var body: some HTML { + table { + thead { + tr(.class("bg-green")) { + th { "Name" } + th { "Heating BTU" } + th { "Cooling BTU" } + th { "Heating CFM" } + th { "Cooling CFM" } + th { "Design CFM" } + } + } + tbody { + for row in rooms { + tr { + td { row.roomName } + td { row.heatingLoad.string(digits: 0) } + td { row.coolingLoad.string(digits: 0) } + td { row.heatingCFM.string(digits: 0) } + td { row.coolingCFM.string(digits: 0) } + td { row.designCFM.value.string(digits: 0) } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/RoomTable.swift b/Sources/PdfClient/Views/RoomTable.swift new file mode 100644 index 0000000..2cd07bc --- /dev/null +++ b/Sources/PdfClient/Views/RoomTable.swift @@ -0,0 +1,50 @@ +import Elementary +import ManualDCore + +struct RoomsTable: HTML, Sendable { + let rooms: [Room] + let projectSHR: Double + + var body: some HTML { + table { + thead { + tr(.class("bg-green")) { + th { "Name" } + th { "Heating BTU" } + th { "Cooling Total BTU" } + th { "Cooling Sensible BTU" } + th { "Register Count" } + } + } + tbody { + for room in rooms { + tr { + td { room.name } + td { room.heatingLoad.string(digits: 0) } + td { room.coolingTotal.string(digits: 0) } + td { + (room.coolingSensible + ?? (room.coolingTotal * projectSHR)).string(digits: 0) + } + td { room.registerCount.string() } + } + } + // Totals + // tr(.class("table-footer")) { + tr { + td(.class("label")) { "Totals" } + td(.class("heating label")) { + rooms.totalHeatingLoad.string(digits: 0) + } + td(.class("coolingTotal label")) { + rooms.totalCoolingLoad.string(digits: 0) + } + td(.class("coolingSensible label")) { + rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0) + } + td {} + } + } + } + } +} diff --git a/Sources/PdfClient/Views/TrunkTable.swift b/Sources/PdfClient/Views/TrunkTable.swift new file mode 100644 index 0000000..40351fb --- /dev/null +++ b/Sources/PdfClient/Views/TrunkTable.swift @@ -0,0 +1,42 @@ +import Elementary +import ManualDCore + +struct TrunkTable: HTML, Sendable { + public let sizes: DuctSizes + public let type: TrunkSize.TrunkType + + var trunks: [DuctSizes.TrunkContainer] { + sizes.trunks.filter { $0.type == type } + } + + var body: some HTML { + table { + thead(.class("bg-green")) { + tr { + th { "Name" } + th { "Dsn CFM" } + th { "Round Size" } + th { "Velocity" } + th { "Final Size" } + th { "Flex Size" } + th { "Height" } + th { "Width" } + } + } + tbody { + for row in trunks { + tr { + td { row.name ?? "" } + td { row.designCFM.value.string(digits: 0) } + td { row.ductSize.roundSize.string() } + td { row.velocity.string() } + td { row.finalSize.string() } + td { row.flexSize.string() } + td { row.ductSize.height?.string() ?? "" } + td { row.width?.string() ?? "" } + } + } + } + } + } +} diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 482bb5a..72dba0b 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -17,6 +17,7 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ npm \ build-essential \ curl \ + wkhtmltopdf \ && rm -r /var/lib/apt/lists/* # Set up a build area