{
+ it("writes fixed PDF page dimensions and a valid cross-reference table", () => {
+ const bytes = buildPdfFromJpegPages([
+ {
+ jpeg: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
+ pixelWidth: 1,
+ pixelHeight: 1,
+ widthPt: 595.276,
+ heightPt: 841.89,
+ },
+ ]);
+ const text = new TextDecoder("latin1").decode(bytes);
+
+ expect(text.startsWith("%PDF-1.4")).toBe(true);
+ expect(text).toContain("/MediaBox [0 0 595.276 841.89]");
+ expect(text).toContain("/Filter /DCTDecode");
+ expect(text).toContain("/PrintScaling /None");
+ expect(text).toContain("xref\n0 6");
+ expect(text.trimEnd().endsWith("%%EOF")).toBe(true);
+ });
+});
diff --git a/src/components/tonies/teddystudio/print/pdfDocument.ts b/src/components/tonies/teddystudio/print/pdfDocument.ts
new file mode 100644
index 00000000..ad93562d
--- /dev/null
+++ b/src/components/tonies/teddystudio/print/pdfDocument.ts
@@ -0,0 +1,118 @@
+export interface PdfPageImage {
+ jpeg: Uint8Array;
+ pixelWidth: number;
+ pixelHeight: number;
+ widthPt: number;
+ heightPt: number;
+}
+
+const encoder = new TextEncoder();
+
+const ascii = (value: string): Uint8Array => encoder.encode(value);
+
+const concatBytes = (chunks: Uint8Array[]): Uint8Array => {
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ const result = new Uint8Array(totalLength);
+ let offset = 0;
+
+ chunks.forEach((chunk) => {
+ result.set(chunk, offset);
+ offset += chunk.length;
+ });
+
+ return result;
+};
+
+const formatNumber = (value: number): string => {
+ const rounded = Math.round(value * 1000) / 1000;
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(3).replace(/0+$/, "");
+};
+
+/**
+ * Minimal PDF writer for page-sized JPEG images. This intentionally has no runtime dependency on
+ * a PDF library: the page geometry is fixed in PDF points and the browser is only used to create
+ * the page bitmap, not to lay out or print HTML.
+ */
+export const buildPdfFromJpegPages = (pages: PdfPageImage[]): Uint8Array => {
+ if (pages.length === 0) {
+ throw new Error("Cannot build an empty PDF.");
+ }
+
+ const objectCount = 2 + pages.length * 3;
+ const objectChunks: Uint8Array[][] = Array.from({ length: objectCount + 1 }, () => []);
+
+ objectChunks[1] = [
+ ascii("<< /Type /Catalog /Pages 2 0 R /ViewerPreferences << /PrintScaling /None >> >>"),
+ ];
+
+ const pageObjectIds = pages.map((_, index) => 3 + index * 3);
+ objectChunks[2] = [
+ ascii(
+ `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds
+ .map((id) => `${id} 0 R`)
+ .join(" ")}] >>`,
+ ),
+ ];
+
+ pages.forEach((page, index) => {
+ const pageObjectId = 3 + index * 3;
+ const imageObjectId = pageObjectId + 1;
+ const contentObjectId = pageObjectId + 2;
+ const pageWidth = formatNumber(page.widthPt);
+ const pageHeight = formatNumber(page.heightPt);
+
+ objectChunks[pageObjectId] = [
+ ascii(
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] ` +
+ `/Resources << /XObject << /Im0 ${imageObjectId} 0 R >> >> ` +
+ `/Contents ${contentObjectId} 0 R >>`,
+ ),
+ ];
+
+ objectChunks[imageObjectId] = [
+ ascii(
+ `<< /Type /XObject /Subtype /Image /Width ${page.pixelWidth} /Height ${page.pixelHeight} ` +
+ `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>\nstream\n`,
+ ),
+ page.jpeg,
+ ascii("\nendstream"),
+ ];
+
+ const content = `q\n${pageWidth} 0 0 ${pageHeight} 0 0 cm\n` + "/Im0 Do\n" + "Q\n";
+ const contentBytes = ascii(content);
+ objectChunks[contentObjectId] = [
+ ascii(`<< /Length ${contentBytes.length} >>\nstream\n`),
+ contentBytes,
+ ascii("endstream"),
+ ];
+ });
+
+ const chunks: Uint8Array[] = [ascii("%PDF-1.4\n")];
+ const offsets = new Array
(objectCount + 1).fill(0);
+ let byteOffset = chunks[0].length;
+
+ for (let objectId = 1; objectId <= objectCount; objectId++) {
+ offsets[objectId] = byteOffset;
+ const header = ascii(`${objectId} 0 obj\n`);
+ const footer = ascii("\nendobj\n");
+ const body = objectChunks[objectId];
+
+ chunks.push(header, ...body, footer);
+ byteOffset +=
+ header.length + body.reduce((sum, chunk) => sum + chunk.length, 0) + footer.length;
+ }
+
+ const xrefOffset = byteOffset;
+ const xrefLines = ["xref", `0 ${objectCount + 1}`, "0000000000 65535 f "];
+ for (let objectId = 1; objectId <= objectCount; objectId++) {
+ xrefLines.push(`${String(offsets[objectId]).padStart(10, "0")} 00000 n `);
+ }
+
+ const trailer =
+ `${xrefLines.join("\n")}\n` +
+ `trailer\n<< /Size ${objectCount + 1} /Root 1 0 R >>\n` +
+ `startxref\n${xrefOffset}\n%%EOF\n`;
+ chunks.push(ascii(trailer));
+
+ return concatBytes(chunks);
+};
diff --git a/src/components/tonies/teddystudio/print/printFile.test.ts b/src/components/tonies/teddystudio/print/printFile.test.ts
new file mode 100644
index 00000000..ae4a37e8
--- /dev/null
+++ b/src/components/tonies/teddystudio/print/printFile.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+import { createTeddyStudioPdfFilename } from "./printFile";
+
+describe("createTeddyStudioPdfFilename", () => {
+ it("uses local date and time in a filesystem-safe filename", () => {
+ const date = new Date(2026, 9, 17, 8, 49, 48);
+
+ expect(createTeddyStudioPdfFilename(date)).toBe(
+ "teddystudio_labels_2026-10-17_08-49-48.pdf",
+ );
+ });
+});
diff --git a/src/components/tonies/teddystudio/print/printFile.ts b/src/components/tonies/teddystudio/print/printFile.ts
new file mode 100644
index 00000000..5be67899
--- /dev/null
+++ b/src/components/tonies/teddystudio/print/printFile.ts
@@ -0,0 +1,10 @@
+const pad2 = (value: number): string => String(value).padStart(2, "0");
+
+/**
+ * Generates a filesystem-safe local timestamp for TeddyStudio PDF exports.
+ * Example: teddystudio_labels_2026-10-17_08-49-48.pdf
+ */
+export const createTeddyStudioPdfFilename = (date = new Date()): string =>
+ `teddystudio_labels_${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}_${pad2(
+ date.getHours(),
+ )}-${pad2(date.getMinutes())}-${pad2(date.getSeconds())}.pdf`;
diff --git a/src/components/tonies/teddystudio/print/printFonts.ts b/src/components/tonies/teddystudio/print/printFonts.ts
new file mode 100644
index 00000000..17cf77af
--- /dev/null
+++ b/src/components/tonies/teddystudio/print/printFonts.ts
@@ -0,0 +1,222 @@
+const LATIN_RANGE =
+ "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD";
+const LATIN_EXT_RANGE =
+ "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF";
+
+type BundledFontSource = {
+ url: string;
+ unicodeRange: string;
+};
+
+type MutableFontFaceSet = FontFaceSet & {
+ add: (font: FontFace) => FontFaceSet;
+};
+
+const BUNDLED_FONTS: Record = {
+ "Atkinson Hyperlegible": [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/atkinson-hyperlegible-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/atkinson-hyperlegible-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ Fredoka: [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/fredoka-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/fredoka-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ "Bubblegum Sans": [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/bubblegum-sans-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/bubblegum-sans-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ "Patrick Hand": [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/patrick-hand-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/patrick-hand-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ "Indie Flower": [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/indie-flower-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/indie-flower-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ Schoolbell: [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/schoolbell-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ ],
+ Inter: [
+ {
+ url: new URL("../../../../assets/fonts/teddystudio/inter-latin.woff2", import.meta.url)
+ .href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/inter-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ Roboto: [
+ {
+ url: new URL("../../../../assets/fonts/teddystudio/roboto-latin.woff2", import.meta.url)
+ .href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/roboto-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ "Open Sans": [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/open-sans-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/open-sans-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+ Merriweather: [
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/merriweather-latin.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_RANGE,
+ },
+ {
+ url: new URL(
+ "../../../../assets/fonts/teddystudio/merriweather-latin-ext.woff2",
+ import.meta.url,
+ ).href,
+ unicodeRange: LATIN_EXT_RANGE,
+ },
+ ],
+};
+
+const loadedBundledFonts = new Map>();
+
+const primaryFamilyName = (fontFamily: string): string => {
+ const first = fontFamily.split(",", 1)[0]?.trim() || fontFamily.trim();
+ return first.replace(/^['"]|['"]$/g, "");
+};
+
+const loadBundledFont = (family: string): Promise => {
+ const cached = loadedBundledFonts.get(family);
+ if (cached) return cached;
+
+ const sources = BUNDLED_FONTS[family];
+ if (!sources || typeof FontFace === "undefined" || !document.fonts) {
+ return Promise.resolve();
+ }
+
+ const promise = Promise.all(
+ sources.map(async ({ url, unicodeRange }) => {
+ const face = new FontFace(family, `url("${url}") format("woff2")`, {
+ style: "normal",
+ weight: "400",
+ unicodeRange,
+ });
+ await face.load();
+ const fontSet = document.fonts as MutableFontFaceSet;
+ if (typeof fontSet.add === "function") {
+ fontSet.add(face);
+ }
+ }),
+ ).then(() => undefined);
+
+ loadedBundledFonts.set(family, promise);
+ return promise;
+};
+
+/**
+ * Ensures that fonts used by the canvas renderer are available without relying on @font-face CSS.
+ * System fonts still resolve through the browser/OS as usual.
+ */
+export const ensurePrintFonts = async (fontFamilies: Iterable): Promise => {
+ const families = Array.from(new Set(fontFamilies)).filter(Boolean);
+
+ await Promise.all(
+ families.map(async (fontFamily) => {
+ const primary = primaryFamilyName(fontFamily);
+ await loadBundledFont(primary);
+ if (document.fonts) {
+ await document.fonts.load(`16px ${fontFamily}`);
+ }
+ }),
+ );
+
+ if (document.fonts) {
+ await document.fonts.ready;
+ }
+};
diff --git a/src/components/tonies/teddystudio/print/printLayout.test.ts b/src/components/tonies/teddystudio/print/printLayout.test.ts
new file mode 100644
index 00000000..ac5ea0a8
--- /dev/null
+++ b/src/components/tonies/teddystudio/print/printLayout.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import type { SettingsState } from "../hooks/useSettings";
+import { createPrintLayout } from "./printLayout";
+
+const settings = (patch: Partial = {}): SettingsState => ({
+ diameter: "40mm",
+ labelShape: "round",
+ printMode: "ImageAndText",
+ width: "50mm",
+ height: "30mm",
+ labelSpacingX: "5mm",
+ labelSpacingY: "5mm",
+ labelBackgroundColor: "#ffffff",
+ paperSize: "A4",
+ customPaperWidth: "210mm",
+ customPaperHeight: "297mm",
+ paperMarginTop: "10mm",
+ paperMarginLeft: "10mm",
+ paperLabelImageBleed: "0mm",
+ textFontSize: "14px",
+ fontFamily: "sans-serif",
+ imagePosition: "center",
+ imageScale: 1,
+ imageBottom: 0,
+ imageLeft: 0,
+ contentPadding: "0mm",
+ showLanguageFlag: false,
+ showModelNo: false,
+ showSeriesOnImageLabel: false,
+ seriesOnImageLabelRotationDeg: 0,
+ seriesOnImageLabelFontSize: "12px",
+ printTrackListInsteadTitle: false,
+ showLabelBorder: true,
+ selectedPaper: undefined,
+ ...patch,
+});
+
+describe("createPrintLayout", () => {
+ it("keeps image/text labels together and wraps groups using millimetres", () => {
+ const pages = createPrintLayout(3, settings());
+
+ expect(pages).toHaveLength(1);
+ expect(pages[0].placements).toEqual([
+ { itemIndex: 0, kind: "image", xMm: 10, yMm: 10, widthMm: 40, heightMm: 40 },
+ { itemIndex: 0, kind: "text", xMm: 55, yMm: 10, widthMm: 40, heightMm: 40 },
+ { itemIndex: 1, kind: "image", xMm: 100, yMm: 10, widthMm: 40, heightMm: 40 },
+ { itemIndex: 1, kind: "text", xMm: 145, yMm: 10, widthMm: 40, heightMm: 40 },
+ { itemIndex: 2, kind: "image", xMm: 10, yMm: 55, widthMm: 40, heightMm: 40 },
+ { itemIndex: 2, kind: "text", xMm: 55, yMm: 55, widthMm: 40, heightMm: 40 },
+ ]);
+ });
+
+ it("lays out one label per item in OnlyImage mode", () => {
+ const pages = createPrintLayout(5, settings({ printMode: "OnlyImage" }));
+
+ expect(pages[0].placements.map(({ xMm, yMm }) => [xMm, yMm])).toEqual([
+ [10, 10],
+ [55, 10],
+ [100, 10],
+ [145, 10],
+ [10, 55],
+ ]);
+ });
+
+ it("stacks image/text labels if the pair cannot fit horizontally", () => {
+ const pages = createPrintLayout(
+ 1,
+ settings({
+ paperSize: "Custom",
+ customPaperWidth: "60mm",
+ customPaperHeight: "120mm",
+ }),
+ );
+
+ expect(pages[0].placements).toEqual([
+ { itemIndex: 0, kind: "image", xMm: 10, yMm: 10, widthMm: 40, heightMm: 40 },
+ { itemIndex: 0, kind: "text", xMm: 10, yMm: 55, widthMm: 40, heightMm: 40 },
+ ]);
+ });
+
+ it("starts another PDF page when the next group no longer fits vertically", () => {
+ const pages = createPrintLayout(
+ 2,
+ settings({
+ printMode: "OnlyImage",
+ paperSize: "Custom",
+ customPaperWidth: "55mm",
+ customPaperHeight: "55mm",
+ paperMarginTop: "10mm",
+ paperMarginLeft: "10mm",
+ }),
+ );
+
+ expect(pages).toHaveLength(2);
+ expect(pages[0].placements[0]).toMatchObject({ itemIndex: 0, xMm: 10, yMm: 10 });
+ expect(pages[1].placements[0]).toMatchObject({ itemIndex: 1, xMm: 10, yMm: 10 });
+ });
+});
diff --git a/src/components/tonies/teddystudio/print/printLayout.ts b/src/components/tonies/teddystudio/print/printLayout.ts
new file mode 100644
index 00000000..7a4b3ccc
--- /dev/null
+++ b/src/components/tonies/teddystudio/print/printLayout.ts
@@ -0,0 +1,173 @@
+import type { SettingsState } from "../hooks/useSettings";
+
+export type PrintLabelKind = "image" | "text";
+
+export interface PrintLabelPlacement {
+ itemIndex: number;
+ kind: PrintLabelKind;
+ xMm: number;
+ yMm: number;
+ widthMm: number;
+ heightMm: number;
+}
+
+export interface PrintPageLayout {
+ widthMm: number;
+ heightMm: number;
+ placements: PrintLabelPlacement[];
+}
+
+const PAPER_SIZES_MM: Record, [number, number]> = {
+ A4: [210, 297],
+ A5: [148, 210],
+ Letter: [215.9, 279.4],
+};
+
+const EPSILON = 0.001;
+
+export const parseMm = (value: string, settingName: string): number => {
+ const parsed = Number.parseFloat(value);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ throw new Error(`Invalid ${settingName}: ${value}`);
+ }
+ return parsed;
+};
+
+export const getPaperSizeMm = (settings: SettingsState): [number, number] => {
+ if (settings.paperSize === "Custom") {
+ const width = parseMm(settings.customPaperWidth, "custom paper width");
+ const height = parseMm(settings.customPaperHeight, "custom paper height");
+ if (width <= 0 || height <= 0) {
+ throw new Error("Custom paper dimensions must be greater than zero.");
+ }
+ return [width, height];
+ }
+
+ return PAPER_SIZES_MM[settings.paperSize];
+};
+
+const getLabelSizeMm = (settings: SettingsState): [number, number] => {
+ if (settings.labelShape === "round") {
+ const diameter = parseMm(settings.diameter, "label diameter");
+ if (diameter <= 0) {
+ throw new Error("Label diameter must be greater than zero.");
+ }
+ return [diameter, diameter];
+ }
+
+ const width = parseMm(settings.width, "label width");
+ const height = parseMm(settings.height, "label height");
+ if (width <= 0 || height <= 0) {
+ throw new Error("Label dimensions must be greater than zero.");
+ }
+ return [width, height];
+};
+
+const getKinds = (settings: SettingsState): PrintLabelKind[] => {
+ switch (settings.printMode) {
+ case "OnlyImage":
+ return ["image"];
+ case "OnlyText":
+ return ["text"];
+ case "ImageAndText":
+ default:
+ return ["image", "text"];
+ }
+};
+
+/**
+ * Builds the physical page/label geometry used by the PDF renderer.
+ * All values are in millimetres; no DOM or CSS measurements are involved.
+ */
+export const createPrintLayout = (
+ itemCount: number,
+ settings: SettingsState,
+): PrintPageLayout[] => {
+ const [pageWidthMm, pageHeightMm] = getPaperSizeMm(settings);
+ const [labelWidthMm, labelHeightMm] = getLabelSizeMm(settings);
+ const marginLeftMm = parseMm(settings.paperMarginLeft, "left paper margin");
+ const marginTopMm = parseMm(settings.paperMarginTop, "top paper margin");
+ const spacingXmm = parseMm(settings.labelSpacingX, "horizontal label spacing");
+ const spacingYmm = parseMm(settings.labelSpacingY, "vertical label spacing");
+ const kinds = getKinds(settings);
+
+ const usableWidthMm = pageWidthMm - marginLeftMm;
+ const usableHeightMm = pageHeightMm - marginTopMm;
+
+ if (usableWidthMm <= 0 || usableHeightMm <= 0) {
+ throw new Error("Paper margins leave no printable area.");
+ }
+ if (labelWidthMm > usableWidthMm + EPSILON || labelHeightMm > usableHeightMm + EPSILON) {
+ throw new Error("The configured label does not fit on the selected paper size.");
+ }
+
+ const horizontalGroupWidthMm =
+ kinds.length * labelWidthMm + Math.max(0, kinds.length - 1) * spacingXmm;
+ const stackGroup = kinds.length > 1 && horizontalGroupWidthMm > usableWidthMm + EPSILON;
+ const groupWidthMm = stackGroup ? labelWidthMm : horizontalGroupWidthMm;
+ const groupHeightMm = stackGroup
+ ? kinds.length * labelHeightMm + Math.max(0, kinds.length - 1) * spacingYmm
+ : labelHeightMm;
+
+ if (groupHeightMm > usableHeightMm + EPSILON) {
+ throw new Error(
+ "The configured image/text label pair does not fit on the selected paper size.",
+ );
+ }
+
+ const pages: PrintPageLayout[] = [];
+ let currentPage: PrintPageLayout = {
+ widthMm: pageWidthMm,
+ heightMm: pageHeightMm,
+ placements: [],
+ };
+ pages.push(currentPage);
+
+ let xMm = marginLeftMm;
+ let yMm = marginTopMm;
+ let rowHeightMm = 0;
+
+ const startNewPage = () => {
+ currentPage = {
+ widthMm: pageWidthMm,
+ heightMm: pageHeightMm,
+ placements: [],
+ };
+ pages.push(currentPage);
+ xMm = marginLeftMm;
+ yMm = marginTopMm;
+ rowHeightMm = 0;
+ };
+
+ const startNewRow = () => {
+ xMm = marginLeftMm;
+ yMm += rowHeightMm + spacingYmm;
+ rowHeightMm = 0;
+ };
+
+ for (let itemIndex = 0; itemIndex < itemCount; itemIndex++) {
+ if (xMm > marginLeftMm + EPSILON && xMm + groupWidthMm > pageWidthMm + EPSILON) {
+ startNewRow();
+ }
+
+ if (yMm + groupHeightMm > pageHeightMm + EPSILON) {
+ startNewPage();
+ }
+
+ kinds.forEach((kind, kindIndex) => {
+ currentPage.placements.push({
+ itemIndex,
+ kind,
+ xMm: stackGroup ? xMm : xMm + kindIndex * (labelWidthMm + spacingXmm),
+ yMm: stackGroup ? yMm + kindIndex * (labelHeightMm + spacingYmm) : yMm,
+ widthMm: labelWidthMm,
+ heightMm: labelHeightMm,
+ });
+ });
+
+ rowHeightMm = Math.max(rowHeightMm, groupHeightMm);
+ xMm += groupWidthMm + spacingXmm;
+ }
+
+ return pages;
+};
diff --git a/src/components/tonies/teddystudio/print/teddyStudioPdf.ts b/src/components/tonies/teddystudio/print/teddyStudioPdf.ts
new file mode 100644
index 00000000..b4b986a8
--- /dev/null
+++ b/src/components/tonies/teddystudio/print/teddyStudioPdf.ts
@@ -0,0 +1,679 @@
+import type { MergedItem } from "../hooks/useCustomItems";
+import type { SettingsState } from "../hooks/useSettings";
+import { buildEffectiveSettings, type LabelOverridesById } from "../types/labelOverrides";
+import { getContrastTextColor } from "../../../../utils/helper";
+import { withBase } from "../../../../utils/basePath";
+import { toImageSrc } from "../../common/utils/imagePathUtils";
+import { loadFlagAsset } from "../../../common/icons/languageFlagAssets";
+import { buildPdfFromJpegPages, type PdfPageImage } from "./pdfDocument";
+import { createPrintLayout, parseMm, type PrintLabelPlacement } from "./printLayout";
+import { ensurePrintFonts } from "./printFonts";
+
+const PRINT_DPI = 300;
+const CSS_DPI = 96;
+const MM_PER_INCH = 25.4;
+const PT_PER_INCH = 72;
+
+const mmToCanvasPx = (mm: number): number => (mm * PRINT_DPI) / MM_PER_INCH;
+const cssPxToCanvasPx = (px: number): number => (px * PRINT_DPI) / CSS_DPI;
+const mmToPt = (mm: number): number => (mm * PT_PER_INCH) / MM_PER_INCH;
+
+const cssPx = (value: string | number, fallback = 14): number => {
+ const parsed = typeof value === "number" ? value : Number.parseFloat(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+};
+
+const canvasToJpeg = async (canvas: HTMLCanvasElement): Promise => {
+ const blob = await new Promise((resolve, reject) => {
+ try {
+ canvas.toBlob(
+ (value) =>
+ value ? resolve(value) : reject(new Error("Could not encode print page.")),
+ "image/jpeg",
+ 0.96,
+ );
+ } catch (error) {
+ reject(error);
+ }
+ });
+ return new Uint8Array(await blob.arrayBuffer());
+};
+
+const loadHtmlImage = (url: string): Promise =>
+ new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => resolve(image);
+ image.onerror = () => reject(new Error(`Could not load image: ${url}`));
+ image.src = url;
+ });
+
+export interface TeddyStudioPdfWarning {
+ code: "image-load-failed" | "font-load-failed";
+ message: string;
+ url?: string;
+}
+
+class ImageCache {
+ private readonly cache = new Map>();
+
+ constructor(private readonly onWarning?: (warning: TeddyStudioPdfWarning) => void) {}
+
+ load(rawUrl: string): Promise {
+ const url = withBase(rawUrl);
+ const cached = this.cache.get(url);
+ if (cached) return cached;
+
+ const promise = this.loadUncached(url);
+ this.cache.set(url, promise);
+ return promise;
+ }
+
+ private async loadUncached(url: string): Promise {
+ try {
+ if (url.startsWith("blob:") || url.startsWith("data:")) {
+ return await loadHtmlImage(url);
+ }
+
+ const response = await fetch(url, { credentials: "same-origin" });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ try {
+ return await loadHtmlImage(objectUrl);
+ } finally {
+ URL.revokeObjectURL(objectUrl);
+ }
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ this.onWarning?.({
+ code: "image-load-failed",
+ message: `Could not load image ${url}: ${reason}`,
+ url,
+ });
+ return null;
+ }
+ }
+}
+
+const objectPositionFactors = (position: string): [number, number] => {
+ const normalized = (position || "center").toLowerCase();
+ const x = normalized.includes("left") ? 0 : normalized.includes("right") ? 1 : 0.5;
+ const y = normalized.includes("top") ? 0 : normalized.includes("bottom") ? 1 : 0.5;
+ return [x, y];
+};
+
+const beginLabelPath = (
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ round: boolean,
+) => {
+ ctx.beginPath();
+ if (round) {
+ ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
+ } else {
+ ctx.rect(x, y, width, height);
+ }
+};
+
+const withLabelClip = (
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ round: boolean,
+ draw: () => void,
+) => {
+ ctx.save();
+ beginLabelPath(ctx, x, y, width, height, round);
+ ctx.clip();
+ draw();
+ ctx.restore();
+};
+
+const drawCoverImage = (
+ ctx: CanvasRenderingContext2D,
+ image: HTMLImageElement,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ position: string,
+ scale: number,
+ offsetLeftCssPx: number,
+ offsetBottomCssPx: number,
+) => {
+ const [positionX, positionY] = objectPositionFactors(position);
+ const imageWidth = image.naturalWidth || image.width;
+ const imageHeight = image.naturalHeight || image.height;
+ const targetRatio = width / height;
+ const sourceRatio = imageWidth / imageHeight;
+
+ let sourceWidth = imageWidth;
+ let sourceHeight = imageHeight;
+ if (sourceRatio > targetRatio) {
+ sourceWidth = imageHeight * targetRatio;
+ } else {
+ sourceHeight = imageWidth / targetRatio;
+ }
+
+ const sourceX = Math.max(0, (imageWidth - sourceWidth) * positionX);
+ const sourceY = Math.max(0, (imageHeight - sourceHeight) * positionY);
+ const safeScale = Number.isFinite(scale) && scale >= 0 ? scale : 1;
+ const scaledWidth = width * safeScale;
+ const scaledHeight = height * safeScale;
+ const offsetLeft = cssPxToCanvasPx(offsetLeftCssPx);
+ const offsetBottom = cssPxToCanvasPx(offsetBottomCssPx);
+ const targetX = x + offsetLeft - (scaledWidth - width) / 2;
+ const targetY = y - offsetBottom - (scaledHeight - height) / 2;
+
+ if (scaledWidth <= 0 || scaledHeight <= 0) return;
+
+ ctx.drawImage(
+ image,
+ sourceX,
+ sourceY,
+ sourceWidth,
+ sourceHeight,
+ targetX,
+ targetY,
+ scaledWidth,
+ scaledHeight,
+ );
+};
+
+const breakLongWord = (ctx: CanvasRenderingContext2D, word: string, maxWidth: number): string[] => {
+ const parts: string[] = [];
+ let current = "";
+
+ for (const char of word) {
+ const candidate = current + char;
+ if (current && ctx.measureText(candidate).width > maxWidth) {
+ parts.push(current);
+ current = char;
+ } else {
+ current = candidate;
+ }
+ }
+
+ if (current) parts.push(current);
+ return parts.length ? parts : [word];
+};
+
+const wrapText = (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] => {
+ if (!text) return [];
+
+ const result: string[] = [];
+
+ text.split("\n").forEach((paragraph) => {
+ if (paragraph === "") {
+ result.push("");
+ return;
+ }
+
+ let line = "";
+ paragraph.split(/\s+/).forEach((word) => {
+ if (ctx.measureText(word).width <= maxWidth) {
+ const candidate = line ? `${line} ${word}` : word;
+ if (line && ctx.measureText(candidate).width > maxWidth) {
+ result.push(line);
+ line = word;
+ } else {
+ line = candidate;
+ }
+ return;
+ }
+
+ if (line) {
+ result.push(line);
+ line = "";
+ }
+
+ const parts = breakLongWord(ctx, word, maxWidth);
+ result.push(...parts.slice(0, -1));
+ line = parts.at(-1) ?? "";
+ });
+
+ if (line) result.push(line);
+ });
+
+ return result;
+};
+
+const drawCenteredLines = (
+ ctx: CanvasRenderingContext2D,
+ lines: string[],
+ x: number,
+ centerY: number,
+ lineHeight: number,
+) => {
+ if (!lines.length) return;
+ const totalHeight = lines.length * lineHeight;
+ let y = centerY - totalHeight / 2 + lineHeight / 2;
+
+ lines.forEach((line) => {
+ ctx.fillText(line, x, y);
+ y += lineHeight;
+ });
+};
+
+const fitCircularFontSize = (
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ fontFamily: string,
+ minFontPx: number,
+ maxFontPx: number,
+ outerRadius: number,
+): number => {
+ let low = minFontPx;
+ let high = Math.max(minFontPx, maxFontPx);
+ let best = minFontPx;
+
+ for (let i = 0; i < 12; i++) {
+ const current = (low + high) / 2;
+ ctx.font = `${current}px ${fontFamily}`;
+ const radius = Math.max(1, outerRadius - current * 0.75);
+ const fits = ctx.measureText(text).width <= Math.PI * 2 * radius;
+ if (fits) {
+ best = current;
+ low = current;
+ } else {
+ high = current;
+ }
+ }
+
+ return best;
+};
+
+const drawCircularText = (
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ centerX: number,
+ centerY: number,
+ size: number,
+ requestedFontSize: number,
+ color: string,
+ fontFamily: string,
+ rotateDeg: number,
+) => {
+ if (!text) return;
+
+ const padding = cssPxToCanvasPx(4);
+ const outerRadius = Math.max(1, (size - 2 * padding) / 2);
+ const fontSize = fitCircularFontSize(
+ ctx,
+ text,
+ fontFamily,
+ cssPxToCanvasPx(10),
+ requestedFontSize,
+ outerRadius,
+ );
+ const textRadius = Math.max(1, outerRadius - fontSize * 0.75);
+
+ ctx.save();
+ ctx.font = `${fontSize}px ${fontFamily}`;
+ ctx.fillStyle = color;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ const characters = Array.from(text);
+ const rawWidths = characters.map((char) => ctx.measureText(char).width);
+ const rawTotalWidth = rawWidths.reduce((sum, width) => sum + width, 0);
+ const measuredWidth = ctx.measureText(text).width;
+ const kerningScale = rawTotalWidth > 0 ? measuredWidth / rawTotalWidth : 1;
+ const widths = rawWidths.map((width) => width * kerningScale);
+ const totalWidth = widths.reduce((sum, width) => sum + width, 0);
+ let distance = -totalWidth / 2;
+ const centerAngle = (270 + rotateDeg) * (Math.PI / 180);
+
+ characters.forEach((char, index) => {
+ const width = widths[index];
+ const charDistance = distance + width / 2;
+ const angle = centerAngle + charDistance / textRadius;
+ const x = centerX + Math.cos(angle) * textRadius;
+ const y = centerY + Math.sin(angle) * textRadius;
+
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.rotate(angle + Math.PI / 2);
+ ctx.fillText(char, 0, 0);
+ ctx.restore();
+
+ distance += width;
+ });
+
+ ctx.restore();
+};
+
+const drawLabelBorder = (
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ round: boolean,
+ color: string,
+) => {
+ const lineWidth = cssPxToCanvasPx(1);
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = lineWidth;
+ beginLabelPath(
+ ctx,
+ x + lineWidth / 2,
+ y + lineWidth / 2,
+ Math.max(0, width - lineWidth),
+ Math.max(0, height - lineWidth),
+ round,
+ );
+ ctx.stroke();
+ ctx.restore();
+};
+
+const drawImageLabel = async (
+ ctx: CanvasRenderingContext2D,
+ item: MergedItem,
+ placement: PrintLabelPlacement,
+ settings: SettingsState,
+ imageCache: ImageCache,
+) => {
+ const x = mmToCanvasPx(placement.xMm);
+ const y = mmToCanvasPx(placement.yMm);
+ const width = mmToCanvasPx(placement.widthMm);
+ const height = mmToCanvasPx(placement.heightMm);
+ const bleed = mmToCanvasPx(parseMm(settings.paperLabelImageBleed, "label image bleed"));
+ const round = settings.labelShape === "round";
+ const backgroundColor = settings.labelBackgroundColor || "#ffffff";
+ const textColor = getContrastTextColor(backgroundColor);
+ const bleedX = x - bleed;
+ const bleedY = y - bleed;
+ const bleedWidth = width + bleed * 2;
+ const bleedHeight = height + bleed * 2;
+
+ withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => {
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(bleedX, bleedY, bleedWidth, bleedHeight);
+ });
+
+ if (item.pic) {
+ const image = await imageCache.load(toImageSrc(item.pic));
+ if (image) {
+ withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => {
+ drawCoverImage(
+ ctx,
+ image,
+ bleedX,
+ bleedY,
+ bleedWidth,
+ bleedHeight,
+ settings.imagePosition,
+ settings.imageScale,
+ settings.imageLeft,
+ settings.imageBottom,
+ );
+ });
+ }
+ }
+
+ if (settings.showSeriesOnImageLabel) {
+ const labelText = `${item.text ?? ""}${item.episodes ? ` - ${item.episodes}` : ""}`;
+ const fontFamily = settings.fontFamily || "sans-serif";
+ const requestedFontSize = cssPxToCanvasPx(
+ cssPx(settings.seriesOnImageLabelFontSize || settings.textFontSize, 12),
+ );
+
+ ctx.fillStyle = textColor;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ if (round) {
+ drawCircularText(
+ ctx,
+ labelText,
+ x + width / 2,
+ y + height / 2,
+ cssPxToCanvasPx(150),
+ requestedFontSize,
+ textColor,
+ fontFamily,
+ settings.seriesOnImageLabelRotationDeg || 0,
+ );
+ } else {
+ withLabelClip(ctx, x, y, width, height, false, () => {
+ ctx.font = `${requestedFontSize}px ${fontFamily}`;
+ ctx.fillText(labelText, x + width / 2, y + height / 2);
+ });
+ }
+ }
+
+ if (settings.showLabelBorder) {
+ drawLabelBorder(ctx, x, y, width, height, round, textColor);
+ }
+};
+
+const loadFlagImage = async (
+ language: string,
+ imageCache: ImageCache,
+): Promise => {
+ const code = language.toUpperCase().split("-")[1];
+ const flagUrl = await loadFlagAsset(code || "UNKNOWN");
+ const fallbackUrl = flagUrl ?? (await loadFlagAsset("UNKNOWN"));
+ return fallbackUrl ? imageCache.load(fallbackUrl) : null;
+};
+
+const drawFlagImage = (
+ ctx: CanvasRenderingContext2D,
+ image: HTMLImageElement,
+ x: number,
+ y: number,
+ height: number,
+) => {
+ const naturalWidth = image.naturalWidth || image.width || 3;
+ const naturalHeight = image.naturalHeight || image.height || 2;
+ const width = height * (naturalWidth / naturalHeight);
+ ctx.drawImage(image, x - width / 2, y, width, height);
+};
+
+const drawTextLabel = async (
+ ctx: CanvasRenderingContext2D,
+ item: MergedItem,
+ placement: PrintLabelPlacement,
+ settings: SettingsState,
+ imageCache: ImageCache,
+) => {
+ const x = mmToCanvasPx(placement.xMm);
+ const y = mmToCanvasPx(placement.yMm);
+ const width = mmToCanvasPx(placement.widthMm);
+ const height = mmToCanvasPx(placement.heightMm);
+ const bleed = mmToCanvasPx(parseMm(settings.paperLabelImageBleed, "label image bleed"));
+ const round = settings.labelShape === "round";
+ const backgroundColor = settings.labelBackgroundColor || "#ffffff";
+ const textColor = getContrastTextColor(backgroundColor);
+ const fontFamily = settings.fontFamily || "sans-serif";
+ const fontSize = cssPxToCanvasPx(cssPx(settings.textFontSize, 14));
+ const smallFontSize = fontSize * 0.833333;
+ const lineHeight = fontSize * 1.2;
+ const smallLineHeight = smallFontSize * 1.2;
+ const contentPadding = mmToCanvasPx(
+ parseMm(settings.contentPadding || "0mm", "content padding"),
+ );
+ const bleedX = x - bleed;
+ const bleedY = y - bleed;
+ const bleedWidth = width + bleed * 2;
+ const bleedHeight = height + bleed * 2;
+ const flagImage =
+ !settings.printTrackListInsteadTitle && settings.showLanguageFlag && item.language
+ ? await loadFlagImage(item.language, imageCache)
+ : null;
+
+ withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => {
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(bleedX, bleedY, bleedWidth, bleedHeight);
+ });
+
+ withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => {
+ const innerLeft = x + contentPadding + cssPxToCanvasPx(8);
+ const innerRight = x + width - contentPadding - cssPxToCanvasPx(8);
+ const innerWidth = Math.max(cssPxToCanvasPx(4), innerRight - innerLeft);
+ const topY = y + contentPadding + cssPxToCanvasPx(2);
+ const bottomY = y + height - contentPadding - cssPxToCanvasPx(2);
+
+ ctx.fillStyle = textColor;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ const topBandHeight = Math.max(cssPxToCanvasPx(12), fontSize);
+ const bottomBandHeight = Math.max(cssPxToCanvasPx(12), smallFontSize);
+ const middleTop = topY + topBandHeight;
+ const middleBottom = bottomY - bottomBandHeight;
+ const middleCenter = (middleTop + middleBottom) / 2;
+
+ if (flagImage) {
+ drawFlagImage(ctx, flagImage, x + width / 2, topY, fontSize);
+ }
+
+ if (settings.printTrackListInsteadTitle) {
+ ctx.font = `${fontSize}px ${fontFamily}`;
+ const lines = wrapText(ctx, item.trackTitles.join("\n"), innerWidth);
+ drawCenteredLines(ctx, lines, x + width / 2, (topY + bottomY) / 2, lineHeight);
+ } else {
+ const title = item.text ?? "";
+ const episodes = item.episodes ?? "";
+ ctx.font = `bold ${fontSize}px ${fontFamily}`;
+ const titleLines = wrapText(ctx, title, innerWidth);
+ ctx.font = `${fontSize}px ${fontFamily}`;
+ const episodeLines = wrapText(ctx, episodes, innerWidth);
+ const gap = titleLines.length && episodeLines.length ? cssPxToCanvasPx(4) : 0;
+ const totalHeight =
+ titleLines.length * lineHeight + episodeLines.length * lineHeight + gap;
+ let lineY = middleCenter - totalHeight / 2 + lineHeight / 2;
+
+ ctx.font = `bold ${fontSize}px ${fontFamily}`;
+ titleLines.forEach((line) => {
+ ctx.fillText(line, x + width / 2, lineY);
+ lineY += lineHeight;
+ });
+ lineY += gap;
+ ctx.font = `${fontSize}px ${fontFamily}`;
+ episodeLines.forEach((line) => {
+ ctx.fillText(line, x + width / 2, lineY);
+ lineY += lineHeight;
+ });
+ }
+
+ if (!settings.printTrackListInsteadTitle && settings.showModelNo && item.model) {
+ ctx.font = `${smallFontSize}px ${fontFamily}`;
+ const modelLines = wrapText(ctx, item.model, innerWidth);
+ if (modelLines.length) {
+ ctx.fillText(modelLines[0], x + width / 2, bottomY - smallLineHeight / 2);
+ }
+ }
+ });
+
+ if (settings.showLabelBorder) {
+ drawLabelBorder(ctx, x, y, width, height, round, textColor);
+ }
+};
+
+const renderPage = async (
+ page: ReturnType[number],
+ items: MergedItem[],
+ settings: SettingsState,
+ labelOverridesById: LabelOverridesById,
+ imageCache: ImageCache,
+): Promise => {
+ const pixelWidth = Math.max(1, Math.round(mmToCanvasPx(page.widthMm)));
+ const pixelHeight = Math.max(1, Math.round(mmToCanvasPx(page.heightMm)));
+ const canvas = document.createElement("canvas");
+ canvas.width = pixelWidth;
+ canvas.height = pixelHeight;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("Canvas is not available in this browser.");
+ }
+
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = "high";
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, pixelWidth, pixelHeight);
+
+ for (const placement of page.placements) {
+ const item = items[placement.itemIndex];
+ if (!item) continue;
+ const id = item.id ?? String(placement.itemIndex);
+ const effectiveSettings = buildEffectiveSettings(settings, labelOverridesById[id]);
+
+ if (placement.kind === "image") {
+ await drawImageLabel(ctx, item, placement, effectiveSettings, imageCache);
+ } else {
+ await drawTextLabel(ctx, item, placement, effectiveSettings, imageCache);
+ }
+ }
+
+ return {
+ jpeg: await canvasToJpeg(canvas),
+ pixelWidth,
+ pixelHeight,
+ widthPt: mmToPt(page.widthMm),
+ heightPt: mmToPt(page.heightMm),
+ };
+};
+
+export interface TeddyStudioPdfOptions {
+ items: MergedItem[];
+ settings: SettingsState;
+ labelOverridesById?: LabelOverridesById;
+ onWarning?: (warning: TeddyStudioPdfWarning) => void;
+}
+
+/**
+ * Creates a print-ready PDF entirely in the frontend. The PDF page and label geometry is based on
+ * millimetres, independent of browser print CSS, @page support or DOM measurements.
+ */
+export const createTeddyStudioPdf = async ({
+ items,
+ settings,
+ labelOverridesById = {},
+ onWarning,
+}: TeddyStudioPdfOptions): Promise => {
+ if (items.length === 0) {
+ throw new Error("There are no labels to print.");
+ }
+
+ const fontFamilies = new Set([settings.fontFamily || "sans-serif"]);
+ for (const item of items) {
+ const override = labelOverridesById[item.id];
+ const effective = buildEffectiveSettings(settings, override);
+ fontFamilies.add(effective.fontFamily || "sans-serif");
+ }
+ try {
+ await ensurePrintFonts(fontFamilies);
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ onWarning?.({
+ code: "font-load-failed",
+ message: `Could not load one or more print fonts: ${reason}`,
+ });
+ }
+
+ const layout = createPrintLayout(items.length, settings);
+ const imageCache = new ImageCache(onWarning);
+ const pages: PdfPageImage[] = [];
+
+ for (const page of layout) {
+ pages.push(await renderPage(page, items, settings, labelOverridesById, imageCache));
+ }
+
+ const bytes = buildPdfFromJpegPages(pages);
+ const buffer = new ArrayBuffer(bytes.byteLength);
+
+ new Uint8Array(buffer).set(bytes);
+ return new Blob([buffer], { type: "application/pdf" });
+};
diff --git a/src/components/tonies/teddystudio/styles/labels.css b/src/components/tonies/teddystudio/styles/labels.css
new file mode 100644
index 00000000..8eba49f6
--- /dev/null
+++ b/src/components/tonies/teddystudio/styles/labels.css
@@ -0,0 +1,116 @@
+.labelElement {
+ position: relative;
+ width: var(--labelElement-width);
+ height: var(--labelElement-height);
+ border-radius: var(--labelElement-radius);
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ overflow: visible;
+ isolation: isolate;
+
+ font-size: var(--text-font-size, 14px);
+ color: black;
+}
+
+.labelElement .labelTintBleed {
+ position: absolute;
+ top: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ left: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ right: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ bottom: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ border-radius: var(--labelElement-radius);
+ background-color: var(--label-background-color, #ffffff);
+ z-index: 0;
+}
+
+.labelElementText {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ overflow: hidden;
+ padding: 0px;
+ border-radius: var(--labelElement-radius);
+ background-color: var(--label-background-color, #ffffff);
+
+ z-index: 0;
+}
+
+.labelElement .labelBg {
+ position: absolute;
+ inset: 0;
+ border-radius: var(--labelElement-radius);
+ background-color: var(--label-background-color, #ffffff);
+ z-index: 0;
+}
+
+.labelElement .labelImageBleed {
+ position: absolute;
+ top: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ left: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ right: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ bottom: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ z-index: 1;
+ background-color: var(--label-background-color, #ffffff);
+ border-radius: var(--labelElement-radius);
+ overflow: hidden;
+}
+
+.labelElement .labelImage {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: cover;
+ object-position: var(--image-position, center);
+}
+
+.labelElement .labelClip {
+ position: absolute;
+ inset: 0;
+ border-radius: var(--labelElement-radius);
+ background: transparent;
+ z-index: 2;
+ overflow: visible;
+}
+
+.labelElement .labelContentBleed {
+ position: absolute;
+ top: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ left: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ right: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ bottom: calc(-1 * var(--paper-label-image-bleed, 0mm));
+ z-index: 3;
+ pointer-events: none;
+ overflow: hidden;
+ border-radius: var(--labelElement-radius);
+}
+
+.labelElement .labelClip::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border: var(--label-border-width, 1px) solid var(--label-border-color, black);
+ border-radius: var(--labelElement-radius);
+ box-sizing: border-box;
+ z-index: 10;
+}
+
+.labelElement .labelOverlay {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ pointer-events: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.traveltonieCouple {
+ border-radius: 16px;
+ border: 1px dashed var(--traveltonie-border-color, #d9d9d9);
+}
From 7030d5d3a962e5608eaa66bc3a445abbc0757d87 Mon Sep 17 00:00:00 2001
From: henryk86 <166761451+henryk86@users.noreply.github.com>
Date: Sat, 17 Oct 2026 11:33:08 +0200
Subject: [PATCH 19/34] auto print after pdf blob is opened
---
.../tonies/teddystudio/TeddyStudio.tsx | 36 +----
.../tonies/teddystudio/print/printFile.ts | 142 ++++++++++++++++++
2 files changed, 147 insertions(+), 31 deletions(-)
diff --git a/src/components/tonies/teddystudio/TeddyStudio.tsx b/src/components/tonies/teddystudio/TeddyStudio.tsx
index 645afe72..ff569b3c 100644
--- a/src/components/tonies/teddystudio/TeddyStudio.tsx
+++ b/src/components/tonies/teddystudio/TeddyStudio.tsx
@@ -23,7 +23,7 @@ import { CustomImages } from "./input/CustomImages";
import { EditLabelModal } from "./modals/EditLabelModal";
import { LabelOverridesById, LabelOverrides } from "./types/labelOverrides";
import { createTeddyStudioPdf } from "./print/teddyStudioPdf";
-import { createTeddyStudioPdfFilename } from "./print/printFile";
+import { createTeddyStudioPdfFilename, downloadPdfBlob, printPdfBlob } from "./print/printFile";
import SelectImageModal from "../common/modals/SelectImageModal";
const { Paragraph } = Typography;
@@ -158,40 +158,16 @@ export const TeddyStudio: React.FC = () => {
});
const filename = createTeddyStudioPdfFilename();
- const pdfFile =
- typeof File !== "undefined"
- ? new File([pdf], filename, { type: "application/pdf" })
- : new Blob([pdf], { type: "application/pdf" });
- const url = URL.createObjectURL(pdfFile);
if (action === "download") {
- const link = document.createElement("a");
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- link.remove();
+ downloadPdfBlob(pdf, filename);
} else {
- // Open the PDF only after it has been fully generated. If the browser blocks the
- // asynchronous popup, fall back to a regular download instead of failing.
- const printWindow = window.open(url, "_blank");
- if (printWindow) {
- try {
- printWindow.opener = null;
- } catch {
- // Nothing to do. The PDF itself is already open.
- }
- } else {
- const link = document.createElement("a");
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- link.remove();
+ const printResult = await printPdfBlob(pdf, filename);
+ if (printResult === "downloaded") {
addNotification(
NotificationTypeEnum.Warning,
t("tonies.teddystudio.printPage"),
- "The browser blocked opening the finished PDF in a new tab. The PDF was downloaded instead.",
+ "The browser could not print or open the finished PDF. The PDF was downloaded instead.",
t("tonies.teddystudio.navigationTitle"),
undefined,
false,
@@ -216,8 +192,6 @@ export const TeddyStudio: React.FC = () => {
false,
);
}
-
- window.setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000);
} catch (error) {
const description = error instanceof Error ? error.message : String(error);
addNotification(
diff --git a/src/components/tonies/teddystudio/print/printFile.ts b/src/components/tonies/teddystudio/print/printFile.ts
index 5be67899..3c20acf6 100644
--- a/src/components/tonies/teddystudio/print/printFile.ts
+++ b/src/components/tonies/teddystudio/print/printFile.ts
@@ -1,5 +1,18 @@
const pad2 = (value: number): string => String(value).padStart(2, "0");
+const revokeObjectUrlLater = (url: string, delayMs = 5 * 60 * 1000) => {
+ window.setTimeout(() => URL.revokeObjectURL(url), delayMs);
+};
+
+const downloadObjectUrl = (url: string, filename: string) => {
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+};
+
/**
* Generates a filesystem-safe local timestamp for TeddyStudio PDF exports.
* Example: teddystudio_labels_2026-10-17_08-49-48.pdf
@@ -8,3 +21,132 @@ export const createTeddyStudioPdfFilename = (date = new Date()): string =>
`teddystudio_labels_${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}_${pad2(
date.getHours(),
)}-${pad2(date.getMinutes())}-${pad2(date.getSeconds())}.pdf`;
+
+export const downloadPdfBlob = (blob: Blob, filename: string): void => {
+ const url = URL.createObjectURL(blob);
+ downloadObjectUrl(url, filename);
+ revokeObjectUrlLater(url);
+};
+
+export type PrintPdfBlobResult = "printed" | "opened" | "downloaded";
+
+/**
+ * Loads the finished PDF into a hidden iframe and asks the browser to print that PDF directly.
+ * If the embedded PDF viewer cannot be printed, the PDF is opened in a new tab instead. If that
+ * popup is blocked as well, the PDF is downloaded as the final fallback.
+ */
+export const printPdfBlob = (
+ blob: Blob,
+ filename: string,
+ options: {
+ loadTimeoutMs?: number;
+ printDelayMs?: number;
+ } = {},
+): Promise => {
+ const { loadTimeoutMs = 10000, printDelayMs = 300 } = options;
+ const url = URL.createObjectURL(blob);
+
+ return new Promise((resolve) => {
+ const iframe = document.createElement("iframe");
+ let finished = false;
+ let loadTimeout: number | undefined;
+ let printTimer: number | undefined;
+
+ iframe.style.position = "fixed";
+ iframe.style.left = "-10000px";
+ iframe.style.top = "-10000px";
+ iframe.style.width = "1px";
+ iframe.style.height = "1px";
+ iframe.style.border = "0";
+ iframe.style.opacity = "0";
+ iframe.style.pointerEvents = "none";
+ iframe.setAttribute("aria-hidden", "true");
+
+ const removeFrame = () => {
+ if (loadTimeout !== undefined) {
+ window.clearTimeout(loadTimeout);
+ }
+ if (printTimer !== undefined) {
+ window.clearTimeout(printTimer);
+ }
+ iframe.remove();
+ };
+
+ const finish = (result: PrintPdfBlobResult) => {
+ if (finished) return;
+ finished = true;
+ resolve(result);
+ };
+
+ const fallback = () => {
+ if (finished) return;
+ removeFrame();
+
+ const printWindow = window.open(url, "_blank");
+ if (printWindow) {
+ try {
+ printWindow.opener = null;
+ } catch {
+ // The PDF is already open. Nothing else is required here.
+ }
+ revokeObjectUrlLater(url);
+ finish("opened");
+ return;
+ }
+
+ downloadObjectUrl(url, filename);
+ revokeObjectUrlLater(url);
+ finish("downloaded");
+ };
+
+ iframe.onload = () => {
+ if (finished) return;
+ if (loadTimeout !== undefined) {
+ window.clearTimeout(loadTimeout);
+ loadTimeout = undefined;
+ }
+
+ printTimer = window.setTimeout(() => {
+ try {
+ const frameWindow = iframe.contentWindow;
+ if (!frameWindow) {
+ fallback();
+ return;
+ }
+
+ frameWindow.addEventListener(
+ "afterprint",
+ () => {
+ removeFrame();
+ URL.revokeObjectURL(url);
+ },
+ { once: true },
+ );
+
+ frameWindow.focus();
+ frameWindow.print();
+
+ // Some PDF viewers do not emit afterprint. Keep the object URL alive long
+ // enough for the viewer and clean it up later as a fallback.
+ window.setTimeout(
+ () => {
+ removeFrame();
+ URL.revokeObjectURL(url);
+ },
+ 5 * 60 * 1000,
+ );
+
+ finish("printed");
+ } catch {
+ fallback();
+ }
+ }, printDelayMs);
+ };
+
+ iframe.onerror = fallback;
+ loadTimeout = window.setTimeout(fallback, loadTimeoutMs);
+
+ document.body.appendChild(iframe);
+ iframe.src = url;
+ });
+};
From f7277072a81624722adc6caed615e2702fc77c61 Mon Sep 17 00:00:00 2001
From: henryk86 <166761451+henryk86@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:51:55 +0200
Subject: [PATCH 20/34] chore: update i18next and react-i18next dependencies to
latest versions
---
package-lock.json | 16 ++++++++--------
package.json | 4 ++--
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index f35eeca6..ec733525 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,13 +20,13 @@
"@vitejs/plugin-react": "^6.1.2",
"antd": "^6.6.6",
"formik": "^2.4.9",
- "i18next": "^26.4.7",
+ "i18next": "^26.5.0",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^4.0.3",
"jszip": "^3.10.2",
"react": "^19.3.1",
"react-dom": "^19.3.1",
- "react-i18next": "^17.0.20",
+ "react-i18next": "^17.1.0",
"react-markdown": "^10.1.0",
"react-minimal-pie-chart": "^9.1.2",
"react-router-dom": "^7.18.4",
@@ -4592,9 +4592,9 @@
}
},
"node_modules/i18next": {
- "version": "26.4.7",
- "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.7.tgz",
- "integrity": "sha512-p/3hqy6EcuqRs6wNwBYwGGC1fzBJSMxV9zMuN814ab/7QjdZbXfjXwuPZdQbngX6KDOTxM01uUiZyqmMB8ghZg==",
+ "version": "26.5.0",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.0.tgz",
+ "integrity": "sha512-OwFFD3ER/BSWsv40teDtkhG7fwgncd7cq1CX7el02IENW9ZiwfWLnMjsQGD/q3SExrxp0+QxwUqXL8F9BKu5+w==",
"funding": [
{
"type": "individual",
@@ -6458,9 +6458,9 @@
"license": "MIT"
},
"node_modules/react-i18next": {
- "version": "17.0.20",
- "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.20.tgz",
- "integrity": "sha512-rzSSirf0NP9vfcUXHZAksIuyMWl7L/T/7nm8S+sSIFuIzku54qcyyVAOZXbawPNpUxGkDx/eosSv4DrpzcxuOQ==",
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.0.tgz",
+ "integrity": "sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
diff --git a/package.json b/package.json
index f707197d..7a5b4155 100644
--- a/package.json
+++ b/package.json
@@ -16,13 +16,13 @@
"@vitejs/plugin-react": "^6.1.2",
"antd": "^6.6.6",
"formik": "^2.4.9",
- "i18next": "^26.4.7",
+ "i18next": "^26.5.0",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^4.0.3",
"jszip": "^3.10.2",
"react": "^19.3.1",
"react-dom": "^19.3.1",
- "react-i18next": "^17.0.20",
+ "react-i18next": "^17.1.0",
"react-markdown": "^10.1.0",
"react-minimal-pie-chart": "^9.1.2",
"react-router-dom": "^7.18.4",
From f4b16dcffd66f8eb0d2bef619771a85c391820a2 Mon Sep 17 00:00:00 2001
From: henryk86 <166761451+henryk86@users.noreply.github.com>
Date: Sun, 18 Oct 2026 10:01:14 +0200
Subject: [PATCH 21/34] feat: add loading spinner and disable repeated clicks
during TonieCard background downloads
---
CHANGELOG.md | 1 +
src/components/tonies/toniecard/TonieCard.tsx | 10 +++++++++-
.../tonies/toniecard/hooks/useTonieCardActions.ts | 9 ++++++++-
3 files changed, 18 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdfb6924..90da759c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,7 @@
- gui: Connected the "edit metadata" action of TAF files in the file browser with the Custom Model Editor, incl. track rows from the TAF header with their duration and a hint when a custom model overrides an original tonie [https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/341](https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/341)
- gui: Track names in the Custom Model Editor keep their position, unnamed tracks in between are no longer dropped
- gui: Box setup can create a port patch for CC3200 boxes (altPort.custom.json) and patch the port of ESP32 boxes, e.g. for a proxy that routes boxes by port (not possible for CC3235 boxes and the Toniebox 2)
+- gui: Show a loading spinner and prevent repeated clicks while a TonieCard background download is in progress
### Commits
diff --git a/src/components/tonies/toniecard/TonieCard.tsx b/src/components/tonies/toniecard/TonieCard.tsx
index 6cb5c16f..c305fd21 100644
--- a/src/components/tonies/toniecard/TonieCard.tsx
+++ b/src/components/tonies/toniecard/TonieCard.tsx
@@ -6,6 +6,7 @@ import {
DownloadOutlined,
EditOutlined,
InfoCircleOutlined,
+ LoadingOutlined,
PlayCircleOutlined,
RetweetOutlined,
StopOutlined,
@@ -238,7 +239,12 @@ export const TonieCard: React.FC<{
overlay,
});
- const { handleLiveClick, handleNoCloudClick, handleBackgroundDownload } = useTonieCardActions({
+ const {
+ handleLiveClick,
+ handleNoCloudClick,
+ handleBackgroundDownload,
+ isBackgroundDownloading,
+ } = useTonieCardActions({
tonieCard,
overlay,
modelTitle,
@@ -492,6 +498,8 @@ export const TonieCard: React.FC<{
+ ) : isBackgroundDownloading ? (
+