Added PDF Generation
This commit is contained in:
@@ -68,6 +68,13 @@
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
button.secondary:hover { background: rgba(59, 130, 246, 0.12); filter: none; }
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
|
||||
button.danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--err);
|
||||
@@ -364,9 +371,15 @@
|
||||
<input type="number" step="0.1" min="0" x-model.number="configForm.spacing_mm" @input="refreshLayoutPreview()">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
|
||||
<span x-text="configSaving ? 'Speichere…' : 'Konfiguration speichern'"></span>
|
||||
</button>
|
||||
<div class="btn-row">
|
||||
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
|
||||
<span x-text="configSaving ? 'Speichere…' : 'Konfiguration speichern'"></span>
|
||||
</button>
|
||||
<button type="button" class="secondary" @click="downloadLayoutPDF()"
|
||||
:disabled="pdfGenerating || !layoutPreview || !configForm.plate_id || !configForm.item_id">
|
||||
<span x-text="pdfGenerating ? 'PDF…' : 'PDF-Vorschau'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="msg-err" x-show="configError" x-text="configError"></p>
|
||||
<p class="msg-ok" x-show="configSuccess" x-text="configSuccess"></p>
|
||||
</form>
|
||||
@@ -377,7 +390,7 @@
|
||||
<div class="layout-stats">
|
||||
<span><strong x-text="layoutPreview.count"></strong> Items passen</span>
|
||||
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</span>
|
||||
<span>Fußabdruck <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' mm'"></strong></span>
|
||||
<span>Item <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' mm'"></strong></span>
|
||||
<span>Zellenabstand <strong x-text="layoutPreview.cell_width_mm.toFixed(1) + ' mm'"></strong></span>
|
||||
<span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
|
||||
</div>
|
||||
@@ -399,7 +412,13 @@
|
||||
<h3 x-text="c.name || ('Konfiguration ' + c.id.slice(0, 8))"></h3>
|
||||
<p class="muted" x-text="configSummary(c)"></p>
|
||||
</div>
|
||||
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="secondary" @click="downloadConfigurationPDF(c.id)"
|
||||
:disabled="pdfGenerating || !!c.preview_error" x-show="c.preview && !c.preview_error">
|
||||
PDF
|
||||
</button>
|
||||
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
|
||||
</div>
|
||||
</header>
|
||||
<p class="msg-err" x-show="c.preview_error" x-text="c.preview_error"></p>
|
||||
<div class="layout-preview-wrap" x-show="c.preview && !c.preview_error">
|
||||
@@ -486,6 +505,7 @@
|
||||
},
|
||||
layoutPreview: null,
|
||||
layoutLoading: false,
|
||||
pdfGenerating: false,
|
||||
_layoutTimer: null,
|
||||
|
||||
apiUrl(path) {
|
||||
@@ -714,6 +734,46 @@
|
||||
}
|
||||
},
|
||||
|
||||
async downloadBlob(path, filename) {
|
||||
this.pdfGenerating = true;
|
||||
this.configError = '';
|
||||
try {
|
||||
const res = await fetch(this.apiUrl(path));
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const err = await res.json();
|
||||
if (err.error) msg = err.error;
|
||||
} catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
this.configError = String(e.message || e);
|
||||
} finally {
|
||||
this.pdfGenerating = false;
|
||||
}
|
||||
},
|
||||
|
||||
downloadLayoutPDF() {
|
||||
const q = new URLSearchParams({
|
||||
plate_id: this.configForm.plate_id,
|
||||
item_id: this.configForm.item_id,
|
||||
spacing_mm: String(Number(this.configForm.spacing_mm) || 0),
|
||||
});
|
||||
this.downloadBlob('/layout/pdf?' + q, 'layout-preview.pdf');
|
||||
},
|
||||
|
||||
downloadConfigurationPDF(id) {
|
||||
this.downloadBlob('/configurations/' + id + '/pdf', 'configuration-' + id.slice(0, 8) + '.pdf');
|
||||
},
|
||||
|
||||
async deleteConfiguration(id) {
|
||||
if (!confirm('Konfiguration wirklich löschen?')) return;
|
||||
this.configError = '';
|
||||
|
||||
@@ -18,6 +18,7 @@ func registerConfigurationRoutes(mux *http.ServeMux, configs *store.Configuratio
|
||||
mux.HandleFunc("DELETE /configurations/{id}", deleteConfiguration(configs))
|
||||
mux.HandleFunc("GET /configurations/{id}/preview", previewConfiguration(configs, plates, items))
|
||||
mux.HandleFunc("GET /layout/preview", layoutPreview(plates, items))
|
||||
registerPDFRoutes(mux, configs, plates, items)
|
||||
}
|
||||
|
||||
type configurationResponse struct {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"printer.backend/internal/model"
|
||||
"printer.backend/internal/platepdf"
|
||||
"printer.backend/internal/store"
|
||||
)
|
||||
|
||||
func registerPDFRoutes(mux *http.ServeMux, configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) {
|
||||
mux.HandleFunc("GET /layout/pdf", layoutPDF(plates, items))
|
||||
mux.HandleFunc("GET /configurations/{id}/pdf", configurationPDF(configs, plates, items))
|
||||
}
|
||||
|
||||
func layoutPDF(plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
plateID := r.URL.Query().Get("plate_id")
|
||||
itemID := r.URL.Query().Get("item_id")
|
||||
if plateID == "" || itemID == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id query params are required"))
|
||||
return
|
||||
}
|
||||
spacing, err := parseSpacing(r.URL.Query().Get("spacing_mm"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
plate, item, preview, svgPath, err := resolveLayoutPDF(plates, items, plateID, itemID, spacing)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
pdf, err := platepdf.Generate(plate, item.Spec, svgPath, preview)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
servePDF(w, pdf, "layout-preview.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func configurationPDF(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
c, err := configs.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
plate, item, preview, svgPath, err := resolveLayoutPDF(plates, items, c.PlateID, c.ItemID, c.SpacingMM)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
pdf, err := platepdf.Generate(plate, item.Spec, svgPath, preview)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
name := c.Name
|
||||
if name == "" {
|
||||
name = "configuration-" + id[:8]
|
||||
}
|
||||
servePDF(w, pdf, sanitizeFilename(name)+".pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLayoutPDF(plates *store.PlateStore, items *store.ItemStore, plateID, itemID string, spacingMM float64) (
|
||||
plate model.Plate, item model.Item, preview model.LayoutPreview, svgPath string, err error,
|
||||
) {
|
||||
plate, err = findPlate(plates, plateID)
|
||||
if err != nil {
|
||||
return plate, item, preview, "", err
|
||||
}
|
||||
item, err = items.Get(itemID)
|
||||
if err != nil {
|
||||
return plate, item, preview, "", err
|
||||
}
|
||||
svgPath, err = items.SVGPath(itemID)
|
||||
if err != nil {
|
||||
return plate, item, preview, "", err
|
||||
}
|
||||
preview, err = buildPreview(plates, items, plateID, itemID, spacingMM)
|
||||
if err != nil {
|
||||
return plate, item, preview, "", err
|
||||
}
|
||||
preview.PlateID = plateID
|
||||
preview.ItemID = itemID
|
||||
return plate, item, preview, svgPath, nil
|
||||
}
|
||||
|
||||
func servePDF(w http.ResponseWriter, pdf []byte, filename string) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(pdf)
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
var b []byte
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||
b = append(b, c)
|
||||
} else if c == ' ' {
|
||||
b = append(b, '_')
|
||||
}
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return "preview"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user