From f6c01de1010d518278a8a2e4775a494ca986e06c Mon Sep 17 00:00:00 2001 From: Ryan Lyo <106399331+cryotato@users.noreply.github.com> Date: Wed, 27 May 2026 15:02:20 +0100 Subject: [PATCH 1/9] attempt1 --- src/App.svelte | 31 ++++++++ .../android/app/src/main/AndroidManifest.xml | 32 +++++--- .../java/ru/mmote/niimblues/MainActivity.java | 78 ++++++++++++++++++- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index ac63b2a3..b7e28df6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,36 @@ + + + +{#if pdfImageSrc} +
+

Success! PDF Received:

+ PDF converted to image + +
+ +
+
+{/if} \ No newline at end of file diff --git a/standalone-apps/capacitor/android/app/src/main/AndroidManifest.xml b/standalone-apps/capacitor/android/app/src/main/AndroidManifest.xml index 9a35dec1..f84c6254 100644 --- a/standalone-apps/capacitor/android/app/src/main/AndroidManifest.xml +++ b/standalone-apps/capacitor/android/app/src/main/AndroidManifest.xml @@ -14,10 +14,30 @@ android:theme="@style/AppTheme.NoActionBarLaunch" android:launchMode="singleTask" android:exported="true"> + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + - + \ No newline at end of file diff --git a/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java b/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java index 181e21f6..c659a7be 100644 --- a/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java +++ b/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java @@ -1,5 +1,81 @@ package ru.mmote.niimblues; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.pdf.PdfRenderer; +import android.net.Uri; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.util.Base64; +import android.util.Log; + import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +import java.io.ByteArrayOutputStream; + +public class MainActivity extends BridgeActivity { + + @Override + public void onResume() { + super.onResume(); + handleIntent(getIntent()); + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIntent(intent); + } + + private void handleIntent(Intent intent) { + // 1. Check if the app was opened via the "Share" menu with a PDF + if (Intent.ACTION_SEND.equals(intent.getAction()) && "application/pdf".equals(intent.getType())) { + Uri pdfUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (pdfUri != null) { + try { + // 2. Open the PDF file natively in Android + ParcelFileDescriptor fd = getContentResolver().openFileDescriptor(pdfUri, "r"); + PdfRenderer renderer = new PdfRenderer(fd); + + // Grab the first page (Page 0) + PdfRenderer.Page page = renderer.openPage(0); + + // 3. Convert the PDF page to a Bitmap Image + // The B1 printer is 203 DPI (roughly 400 pixels wide for standard labels) + int width = 400; + int height = (int) (width * ((float) page.getHeight() / page.getWidth())); + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + + // Fill the background with white (otherwise it might be transparent/black) + bitmap.eraseColor(Color.WHITE); + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_PRINT); + + // 4. Compress the image to a Base64 String so JavaScript can read it + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); + byte[] imageBytes = baos.toByteArray(); + String base64Image = "data:image/png;base64," + Base64.encodeToString(imageBytes, Base64.NO_WRAP); + + // 5. Inject the image into the Javascript Frontend + if (bridge != null && bridge.getWebView() != null) { + String js = "window.dispatchEvent(new CustomEvent('pdfReceived', { detail: '" + base64Image + "' }));"; + bridge.getWebView().evaluateJavascript(js, null); + } + + // Clean up memory + page.close(); + renderer.close(); + fd.close(); + + // Remove the intent so it doesn't process twice + setIntent(new Intent()); + + } catch (Exception e) { + Log.e("NiimBlue PDF", "Error converting PDF to Image", e); + } + } + } + } +} \ No newline at end of file From 3f7ebf01c1f6ef6d27f480c02f01ca1f2f0c1c4c Mon Sep 17 00:00:00 2001 From: Ryan Lyo <106399331+cryotato@users.noreply.github.com> Date: Wed, 27 May 2026 16:48:21 +0100 Subject: [PATCH 2/9] it did not work --- GitHub.lnk | Bin 0 -> 1326 bytes package-lock.json | 58 ++++++++++-------- standalone-apps/capacitor/capacitor.config.ts | 2 +- 3 files changed, 34 insertions(+), 26 deletions(-) create mode 100644 GitHub.lnk diff --git a/GitHub.lnk b/GitHub.lnk new file mode 100644 index 0000000000000000000000000000000000000000..8e13a31dd32b70f30a9d99caa8bcfd8e23afee4f GIT binary patch literal 1326 zcma)6e@IhN6h2c+Ewv3zR9ae5nB{y^Q#UY1+jNjqLj{8um^yK``D2@D1VNC0WQs*) zA}E%c3=~QFAz0#H6-2T!L_;tl3JQ@0L4PFlo#$MdMcv1D&b{ZId%kneJ@4fakt`|z zl4wejlyikbNRIiZYgIA*^9!<)&Q~{Jn=@6jb80IGj{Tp%g^RlNU?f+6l3#Sp|=b2zL8L zrj>RT%iL5#PO^{#l1K+xjg<{g8l{p7v3P!%lCe@#7LW$ND9ZQoPKYz-Qqea{bqdGAWY;?2n;he-3WDoN}!$Q)4;NQC|kP#fLp>wfyR4#;q*Y zjRE5<<&qV=Cggyv6b+bxT>w{}buz#s4^RMHnKW5Y~D; zCE>|iJzdTn`tFGwa8~U#GA9$q198CSJ4&EL(xZ1oe@8gi%@6WsjCOzt-nW+KJkHC~ zPyC3yexS=f-2i(KJ1{{CP=pezfg`?_O-93}qj@tWodumm8F-{pk2lj33z{vyy8~cH1Cu}$_5Tq&)|}7Xnu^5io39lQW@)Nm zKE`tb_T7M#59jpvI*Wg}8F5qo97_W**}Z7HVWhp@G-T4L2C|#ml(1K`g9+#ypH?nL R44Nm6x4dB~W2xI^N literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index 22401649..f89f0a31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1606,9 +1606,9 @@ ] }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2399,9 +2399,9 @@ } }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "dev": true, "license": "MIT" }, @@ -2639,13 +2639,21 @@ } }, "node_modules/esrap": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", - "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } } }, "node_modules/esrecurse": { @@ -3276,9 +3284,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3461,9 +3469,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3481,7 +3489,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3956,24 +3964,24 @@ } }, "node_modules/svelte": { - "version": "5.53.7", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.7.tgz", - "integrity": "sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==", + "version": "5.55.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", + "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.3", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.2", + "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -4483,9 +4491,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "optional": true, "engines": { diff --git a/standalone-apps/capacitor/capacitor.config.ts b/standalone-apps/capacitor/capacitor.config.ts index 465dc778..66980cf0 100644 --- a/standalone-apps/capacitor/capacitor.config.ts +++ b/standalone-apps/capacitor/capacitor.config.ts @@ -3,7 +3,7 @@ import { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { appId: "ru.mmote.niimblues", appName: "NiimBlues", - webDir: "www", + webDir: "../../dist", plugins: { SplashScreen: { launchShowDuration: 0, From f0ba26604e0965d802200384eaa6af3cf15f3e6c Mon Sep 17 00:00:00 2001 From: Ryan Lyo <106399331+cryotato@users.noreply.github.com> Date: Wed, 27 May 2026 17:35:13 +0100 Subject: [PATCH 3/9] attempt2 --- .repomixignore | 45 + repomix-output.xml | 9942 +++++++++++++++++ src/App.svelte | 36 +- .../java/ru/mmote/niimblues/MainActivity.java | 76 +- .../ru/mmote/niimblues/PdfIntentPlugin.java | 101 + 5 files changed, 10116 insertions(+), 84 deletions(-) create mode 100644 .repomixignore create mode 100644 repomix-output.xml create mode 100644 standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/PdfIntentPlugin.java diff --git a/.repomixignore b/.repomixignore new file mode 100644 index 00000000..3757f3dd --- /dev/null +++ b/.repomixignore @@ -0,0 +1,45 @@ +# --- Lockfiles & Dependencies --- +package-lock.json +yarn.lock +pnpm-lock.yaml + +# --- Media & Images (LLMs can't read code from images) --- +*.png +*.jpg +*.jpeg +*.ico +*.svg +*.webmanifest +*.lnk + +# --- Translations / Locales (Usually massive and irrelevant to logic) --- +src/locale/dicts/ + +# --- Generated / Asset Lists --- +# Assuming this is a massive list of Material Design Icons +src/styles/mdi_icons.ts +gen-mdi-list.mjs + +# --- CI/CD & Repository Metadata --- +.gitea/ +.github/ +.dockerignore +LICENSE + +# --- iOS Boilerplate & Assets --- +standalone-apps/capacitor/ios/App/App/Assets.xcassets/ +standalone-apps/capacitor/ios/App/App.xcodeproj/ +standalone-apps/capacitor/ios/App/App.xcworkspace/ +standalone-apps/capacitor/ios/App/App/Base.lproj/ + +# --- Android Boilerplate & Assets --- +standalone-apps/capacitor/android/app/src/main/res/ +standalone-apps/capacitor/android/gradle/ +standalone-apps/capacitor/android/gradlew +standalone-apps/capacitor/android/gradlew.bat +*.jar + +# --- Build Outputs (Just in case you generated them locally) --- +dist/ +build/ +.svelte-kit/ \ No newline at end of file diff --git a/repomix-output.xml b/repomix-output.xml new file mode 100644 index 00000000..83809d03 --- /dev/null +++ b/repomix-output.xml @@ -0,0 +1,9942 @@ +This file is a merged representation of the entire codebase, combined into a single document by Repomix. + + +This section contains a summary of this file. + + +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + + + +The content is organized as follows: +1. This summary section +2. Repository information +3. Directory structure +4. Repository files (if enabled) +5. Multiple file entries, each consisting of: + - File path as an attribute + - Full contents of the file + + + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + + + +- Some files may have been excluded based on .gitignore rules and Repomix's configuration +- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files +- Files matching patterns in .gitignore are excluded +- Files matching default ignore patterns are excluded +- Files are sorted by Git change count (files with more changes are at the bottom) + + + + + +.gitignore +.prettierrc +.repomixignore +CONTRIBUTING.md +Dockerfile +eslint.config.js +index.html +package.json +README.md +src/App.svelte +src/components/basic/AppModal.svelte +src/components/basic/BrowserWarning.svelte +src/components/basic/FirmwareUpdater.svelte +src/components/basic/MdIcon.svelte +src/components/basic/ParamLockButton.svelte +src/components/DebugStuff.svelte +src/components/designer-controls/ArUcoParamsControls.svelte +src/components/designer-controls/BarcodeParamsControls.svelte +src/components/designer-controls/CsvControl.svelte +src/components/designer-controls/DpiSelector.svelte +src/components/designer-controls/FontFamilyPicker.svelte +src/components/designer-controls/FontsMenu.svelte +src/components/designer-controls/GenericObjectParamsControls.svelte +src/components/designer-controls/IconPicker.svelte +src/components/designer-controls/LabelPresetsBrowser.svelte +src/components/designer-controls/LabelPropsEditor.svelte +src/components/designer-controls/ObjectPicker.svelte +src/components/designer-controls/ObjectPositionControls.svelte +src/components/designer-controls/PdfImportButton.svelte +src/components/designer-controls/QRCodeParamsControls.svelte +src/components/designer-controls/SavedLabelsBrowser.svelte +src/components/designer-controls/SavedLabelsMenu.svelte +src/components/designer-controls/TextParamsControls.svelte +src/components/designer-controls/VariableInsertControl.svelte +src/components/designer-controls/VectorParamsControls.svelte +src/components/designer-controls/ZplImportButton.svelte +src/components/LabelDesigner.svelte +src/components/MainPage.svelte +src/components/PrinterConnector.svelte +src/components/PrintPreview.svelte +src/defaults.ts +src/fabric-object/aruco.ts +src/fabric-object/barcode.ts +src/fabric-object/custom_canvas.ts +src/fabric-object/qrcode.ts +src/fabric-object/textbox-ext.ts +src/index.ts +src/locale/index.ts +src/stores.ts +src/styles/font.scss +src/styles/style.scss +src/types.ts +src/utils/barcode.ts +src/utils/browsers.ts +src/utils/canvas_preprocess.ts +src/utils/canvas_utils.ts +src/utils/file_utils.ts +src/utils/i18n.ts +src/utils/label_designer_object_helper.ts +src/utils/label_designer_utils.ts +src/utils/persistence.ts +src/utils/post_process.ts +src/utils/qrcode.ts +src/utils/toasts.ts +src/utils/undo_redo.ts +src/vite-env.d.ts +standalone-apps/capacitor/.gitattributes +standalone-apps/capacitor/.gitignore +standalone-apps/capacitor/android/.gitignore +standalone-apps/capacitor/android/app/.gitignore +standalone-apps/capacitor/android/app/build.gradle +standalone-apps/capacitor/android/app/capacitor.build.gradle +standalone-apps/capacitor/android/app/proguard-rules.pro +standalone-apps/capacitor/android/app/src/main/AndroidManifest.xml +standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java +standalone-apps/capacitor/android/build.gradle +standalone-apps/capacitor/android/capacitor.settings.gradle +standalone-apps/capacitor/android/gradle.properties +standalone-apps/capacitor/android/settings.gradle +standalone-apps/capacitor/android/variables.gradle +standalone-apps/capacitor/capacitor.config.ts +standalone-apps/capacitor/ios/.gitignore +standalone-apps/capacitor/ios/App/App/App.entitlements +standalone-apps/capacitor/ios/App/App/AppDelegate.swift +standalone-apps/capacitor/ios/App/App/Info.plist +standalone-apps/capacitor/ios/App/Podfile +standalone-apps/capacitor/package.json +standalone-apps/capacitor/README.md +standalone-apps/tauri-windows/.gitignore +standalone-apps/tauri-windows/package.json +standalone-apps/tauri-windows/src-tauri/.gitignore +standalone-apps/tauri-windows/src-tauri/build.rs +standalone-apps/tauri-windows/src-tauri/capabilities/default.json +standalone-apps/tauri-windows/src-tauri/Cargo.toml +standalone-apps/tauri-windows/src-tauri/src/lib.rs +standalone-apps/tauri-windows/src-tauri/src/main.rs +standalone-apps/tauri-windows/src-tauri/tauri.conf.json +svelte.config.js +tsconfig.json +vite.config.ts + + + +This section contains the contents of the repository's files. + + +/.vscode +/dist +/node_modules +.idea +CLAUDE.md + + + +{ + "tabWidth": 2, + "singleQuote": false, + "printWidth": 120, + "semi": true, + "bracketSameLine": true, + "endOfLine": "auto", + "useTabs": false, + "plugins": ["prettier-plugin-svelte"] +} + + + +# --- Lockfiles & Dependencies --- +package-lock.json +yarn.lock +pnpm-lock.yaml + +# --- Media & Images (LLMs can't read code from images) --- +*.png +*.jpg +*.jpeg +*.ico +*.svg +*.webmanifest +*.lnk + +# --- Translations / Locales (Usually massive and irrelevant to logic) --- +src/locale/dicts/ + +# --- Generated / Asset Lists --- +# Assuming this is a massive list of Material Design Icons +src/styles/mdi_icons.ts +gen-mdi-list.mjs + +# --- CI/CD & Repository Metadata --- +.gitea/ +.github/ +.dockerignore +LICENSE + +# --- iOS Boilerplate & Assets --- +standalone-apps/capacitor/ios/App/App/Assets.xcassets/ +standalone-apps/capacitor/ios/App/App.xcodeproj/ +standalone-apps/capacitor/ios/App/App.xcworkspace/ +standalone-apps/capacitor/ios/App/App/Base.lproj/ + +# --- Android Boilerplate & Assets --- +standalone-apps/capacitor/android/app/src/main/res/ +standalone-apps/capacitor/android/gradle/ +standalone-apps/capacitor/android/gradlew +standalone-apps/capacitor/android/gradlew.bat +*.jar + +# --- Build Outputs (Just in case you generated them locally) --- +dist/ +build/ +.svelte-kit/ + + + +import eslint from "@eslint/js"; +import svelte from "eslint-plugin-svelte"; +import globals from "globals"; +import svelteConfig from "./svelte.config.js"; +import tseslint from "typescript-eslint"; +import { defineConfig } from "eslint/config"; + +export default defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + svelte.configs.recommended, + [ + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + }, + { + files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], + + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: [".svelte"], // Add support for additional file extensions, such as .svelte + parser: tseslint.parser, + svelteConfig, + }, + }, + }, + { + ignores: ["dist/", "capacitor/"], + }, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, + ] +); + + + + + + + + + + + + + + + + + + + + + + + + + %VITE_PAGE_TITLE% + + +
+ + + +
+ + + + +{#if !caps.webSerial && !caps.webBluetooth && !caps.capacitorBle} + +{/if} + +{#if antiFingerprinting} + +{/if} + + + + + + + + + {String.fromCodePoint(iconCodepoints[icon])} + + + + + + + + + + + + + + + + +
+ {$tr("params.label.head_density")} + + + + + + + {$tr("params.label.dpmm")} + +
+
+ + + + + + + + + + + + + +{#if selectedObject instanceof fabric.FabricImage} +
+ + + +
+{/if} + + +
+ + + + +
+ + {#each presets as item, idx (item)} +
onItemSelected(idx)} + onclick={() => onItemSelected(idx)}> + +
+ {/each} +
+ + +
+ + + + + + +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ + + + +
+ + + + +
+ {#each labels as item, idx (item.id ?? item.timestamp)} +
onItemClicked(idx)} + onclick={() => onItemClicked(idx)} + role="button"> + +
+ {/each} +
+ + +
+ + + + +
+ + + +
+
+ + + + +
+ {#if $connectionState === "connected"} + + + + {#if connectionType === "serial"} + + {:else} + + {/if} + + + {$printerMeta?.model ?? $connectedPrinterName} + + {#if $heartbeatData?.chargeLevel} + + + + {/if} + {:else} + {#if featureSupport.webBluetooth} + + {/if} + {#if featureSupport.webSerial} + + {/if} + {#if featureSupport.capacitorBle} + + {/if} + {/if} + + {#if $connectionState !== "connected"} + + {/if} + + {#if $connectionState === "connected"} + + {/if} +
+ + +
+ + +/* noto-sans-latin-wght-normal */ +@font-face { + font-family: "Noto Sans Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/noto-sans/files/noto-sans-latin-wght-normal.woff2) format("woff2-variations"); + unicode-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; +} + +/* noto-sans-cyrillic-wght-normal */ +@font-face { + font-family: "Noto Sans Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/noto-sans/files/noto-sans-cyrillic-wght-normal.woff2) format("woff2-variations"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* material-icons */ +@font-face { + font-family: "Material Icons"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(material-icons/iconfont/material-icons.woff2) format("woff2"); +} + + + +@import "bootstrap/scss/bootstrap-utilities"; +@import "bootstrap/scss/reboot"; +@import "bootstrap/scss/type"; +@import "bootstrap/scss/containers"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/buttons"; +@import "bootstrap/scss/button-group"; +@import "bootstrap/scss/forms"; +@import "bootstrap/scss/dropdown"; +@import "bootstrap/scss/modal"; +@import "bootstrap/scss/close"; +@import "bootstrap/scss/progress"; +@import "bootstrap/scss/alert"; +@import "bootstrap/scss/transitions"; +@import "font"; + +body { + font-family: "Noto Sans Variable", sans-serif; +} + +.toastify.toast-danger { + background: var(--bs-danger-bg-subtle); + border: 1px solid var(--bs-danger-border-subtle); + color: var(--bs-danger-text-emphasis); +} + +.toastify.toast-info { + background: var(--bs-success-bg-subtle); + border: 1px solid var(--bs-success-border-subtle); + color: var(--bs-success-text-emphasis); +} + +.cursor-help { + cursor: help; +} + + + +type EAN13BitPattern = { + A: string; + B: string; + C: string; +}; +const ean13_bp: Record<"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9", EAN13BitPattern> = { + "0": { A: "0001101", B: "0100111", C: "1110010" }, + "1": { A: "0011001", B: "0110011", C: "1100110" }, + "2": { A: "0010011", B: "0011011", C: "1101100" }, + "3": { A: "0111101", B: "0100001", C: "1000010" }, + "4": { A: "0100011", B: "0011101", C: "1011100" }, + "5": { A: "0110001", B: "0111001", C: "1001110" }, + "6": { A: "0101111", B: "0000101", C: "1010000" }, + "7": { A: "0111011", B: "0010001", C: "1000100" }, + "8": { A: "0110111", B: "0001001", C: "1001000" }, + "9": { A: "0001011", B: "0010111", C: "1110100" }, +}; +const ean13_table_switch_mask = { + "0": "AAAAAA", + "1": "AABABB", + "2": "AABBAB", + "3": "AABBBA", + "4": "ABAABB", + "5": "ABBAAB", + "6": "ABBBAA", + "7": "ABABAB", + "8": "ABABBA", + "9": "ABBABA", +}; + +/** + * Convert 12 or 13 digit numbers to EAN13 barcode + * @param data string of 12 or 13 digits + * @returns string of EAN13 barcode, it is an array of 95 characters, each character is either 0 or 1, representing a white or black stripe, respectively. + */ +export function ean13(data: string): { text: string; bandcode: string } { + if (data.length > 13) throw new Error("Data too long for EAN13"); + if (data.length < 12) data = data.padEnd(12, "0"); + if (/^\d+$/.test(data) === false) throw new Error("Invalid character in EAN13"); + + // checksum + let checksum = 0; + for (let i = 0; i < 12; i++) { + const digit = parseInt(data[i], 10); + checksum += (i % 2 === 0 ? 1 : 3) * digit; + } + checksum = (10 - (checksum % 10)) % 10; + if (data.length === 12) data += checksum.toString(); + else if (data.length === 13 && data[12] !== checksum.toString()) throw new Error("Invalid checksum in EAN13"); + + const result: string[] = []; + + result.push("101"); // Start + // Left Side + const table_switch = ean13_table_switch_mask[data[0] as keyof typeof ean13_table_switch_mask]; + for (let i = 1; i < 7; i++) { + const digit = data[i]; + const tab = table_switch[i - 1] as keyof EAN13BitPattern; + const coding = ean13_bp[digit as keyof typeof ean13_bp][tab]; + result.push(coding); + } + result.push("01010"); // Center Guard + // Right Side + for (let i = 7; i < 13; i++) { + const digit = data[i]; + const coding = ean13_bp[digit as keyof typeof ean13_bp].C; + result.push(coding); + } + result.push("101"); // Stop + + return { + text: data, + bandcode: result.join(""), + }; +} + +// -------------------------------- + +type Code128BitPattern = { + ascii: number; + code: string; +}; +const code128_bp: Code128BitPattern[] = [ + { ascii: 32, code: "11011001100" }, // A: SP, B: SP, C: 00, BandCode: 212222 + { ascii: 33, code: "11001101100" }, // A: !, B: !, C: 01, BandCode: 222122 + { ascii: 34, code: "11001100110" }, // A: “, B: “, C: 02, BandCode: 222221 + { ascii: 35, code: "10010011000" }, // A: #, B: #, C: 03, BandCode: 121223 + { ascii: 36, code: "100h0001100" }, // A: $, B: $, C: 04, BandCode: 121322 + { ascii: 37, code: "10001001100" }, // A: %, B: %, C: 05, BandCode: 131222 + { ascii: 38, code: "10011001000" }, // A: &, B: &, C: 06, BandCode: 122213 + { ascii: 39, code: "10011000100" }, // A: ‘, B: ‘, C: 07, BandCode: 122312 + { ascii: 40, code: "10001100100" }, // A: (, B: (, C: 08, BandCode: 132212 + { ascii: 41, code: "1100h00h000" }, // A: ), B: ), C: 09, BandCode: 221213 + { ascii: 42, code: "11001000100" }, // A: *, B: *, C: 10, BandCode: 221312 + { ascii: 43, code: "11000100100" }, // A: +, B: +, C: 11, BandCode: 231212 + { ascii: 44, code: "10110011100" }, // A: ,, B: ,, C: 12, BandCode: 112232 + { ascii: 45, code: "10011011100" }, // A: -, B: -, C: 13, BandCode: 122132 + { ascii: 46, code: "10011001110" }, // A: ., B: ., C: 14, BandCode: 122231 + { ascii: 47, code: "10111001100" }, // A: /, B: /, C: 15, BandCode: 113222 + { ascii: 48, code: "10011101100" }, // A: 0, B: 0, C: 16, BandCode: 123122 + { ascii: 49, code: "10011100110" }, // A: 1, B: 1, C: 17, BandCode: 123221 + { ascii: 50, code: "11001110010" }, // A: 2, B: 2, C: 18, BandCode: 223211 + { ascii: 51, code: "11001011100" }, // A: 3, B: 3, C: 19, BandCode: 221132 + { ascii: 52, code: "11001001110" }, // A: 4, B: 4, C: 20, BandCode: 221231 + { ascii: 53, code: "11011100100" }, // A: 5, B: 5, C: 21, BandCode: 213212 + { ascii: 54, code: "11001110100" }, // A: 6, B: 6, C: 22, BandCode: 223112 + { ascii: 55, code: "11101101110" }, // A: 7, B: 7, C: 23, BandCode: 312131 + { ascii: 56, code: "11101001100" }, // A: 8, B: 8, C: 24, BandCode: 311222 + { ascii: 57, code: "11100101100" }, // A: 9, B: 9, C: 25, BandCode: 321122 + { ascii: 58, code: "11100100110" }, // A: :, B: :, C: 26, BandCode: 321221 + { ascii: 59, code: "11101100100" }, // A: ;, B: ;, C: 27, BandCode: 312212 + { ascii: 60, code: "11100110100" }, // A: <, B: <, C: 28, BandCode: 322112 + { ascii: 61, code: "11100110010" }, // A: =, B: =, C: 29, BandCode: 322211 + { ascii: 62, code: "11011011000" }, // A: >, B: >, C: 30, BandCode: 212123 + { ascii: 63, code: "11011000110" }, // A: ?, B: ?, C: 31, BandCode: 212321 + { ascii: 64, code: "11000110110" }, // A: @, B: @, C: 32, BandCode: 232121 + { ascii: 65, code: "10100011000" }, // A: A, B: A, C: 33, BandCode: 111323 + { ascii: 66, code: "10001011000" }, // A: B, B: B, C: 34, BandCode: 131123 + { ascii: 67, code: "10001000110" }, // A: C, B: C, C: 35, BandCode: 131321 + { ascii: 68, code: "10110001000" }, // A: D, B: D, C: 36, BandCode: 112313 + { ascii: 69, code: "10001101000" }, // A: E, B: E, C: 37, BandCode: 132113 + { ascii: 70, code: "10001100010" }, // A: F, B: F, C: 38, BandCode: 132311 + { ascii: 71, code: "11010001000" }, // A: G, B: G, C: 39, BandCode: 211313 + { ascii: 72, code: "11000101000" }, // A: H, B: H, C: 40, BandCode: 231113 + { ascii: 73, code: "11000100010" }, // A: I, B: I, C: 41, BandCode: 231311 + { ascii: 74, code: "10110111000" }, // A: J, B: J, C: 42, BandCode: 112133 + { ascii: 75, code: "10110001110" }, // A: K, B: K, C: 43, BandCode: 112331 + { ascii: 76, code: "10001101110" }, // A: L, B: L, C: 44, BandCode: 132131 + { ascii: 77, code: "10111011000" }, // A: M, B: M, C: 45, BandCode: 113123 + { ascii: 78, code: "10111000110" }, // A: N, B: N, C: 46, BandCode: 113321 + { ascii: 79, code: "10001110110" }, // A: O, B: O, C: 47, BandCode: 133121 + { ascii: 80, code: "11101110110" }, // A: P, B: P, C: 48, BandCode: 313121 + { ascii: 81, code: "11010001110" }, // A: Q, B: Q, C: 49, BandCode: 211331 + { ascii: 82, code: "11000101110" }, // A: R, B: R, C: 50, BandCode: 231131 + { ascii: 83, code: "11011101000" }, // A: S, B: S, C: 51, BandCode: 213113 + { ascii: 84, code: "11011100010" }, // A: T, B: T, C: 52, BandCode: 213311 + { ascii: 85, code: "11011101110" }, // A: U, B: U, C: 53, BandCode: 213131 + { ascii: 86, code: "11101011000" }, // A: V, B: V, C: 54, BandCode: 311123 + { ascii: 87, code: "11101000110" }, // A: W, B: W, C: 55, BandCode: 311321 + { ascii: 88, code: "11100010110" }, // A: X, B: X, C: 56, BandCode: 331121 + { ascii: 89, code: "11101101000" }, // A: Y, B: Y, C: 57, BandCode: 312113 + { ascii: 90, code: "11101100010" }, // A: Z, B: Z, C: 58, BandCode: 312311 + { ascii: 91, code: "11100011010" }, // A: [, B: [, C: 59, BandCode: 332111 + { ascii: 92, code: "11101111010" }, // A: \, B: \, C: 60, BandCode: 314111 + { ascii: 93, code: "11001000010" }, // A: ], B: ], C: 61, BandCode: 221411 + { ascii: 94, code: "11110001010" }, // A: ^, B: ^, C: 62, BandCode: 431111 + { ascii: 95, code: "10100110000" }, // A: _, B: _, C: 63, BandCode: 111224 + { ascii: 96, code: "10100001100" }, // A: NUL, B: `, C: 64, BandCode: 111422 + { ascii: 97, code: "10010110000" }, // A: SOH, B: a, C: 65, BandCode: 121124 + { ascii: 98, code: "10010000110" }, // A: STX, B: b, C: 66, BandCode: 121421 + { ascii: 99, code: "10000101100" }, // A: ETX, B: c, C: 67, BandCode: 141122 + { ascii: 100, code: "10000100110" }, // A: EOT, B: d, C: 68, BandCode: 141221 + { ascii: 101, code: "10110010000" }, // A: ENQ, B: e, C: 69, BandCode: 112214 + { ascii: 102, code: "10110000100" }, // A: ACK, B: f, C: 70, BandCode: 112412 + { ascii: 103, code: "10011010000" }, // A: BEL, B: g, C: 71, BandCode: 122114 + { ascii: 104, code: "10011000010" }, // A: BS, B: h, C: 72, BandCode: 122411 + { ascii: 105, code: "10000110100" }, // A: HT, B: i, C: 73, BandCode: 142112 + { ascii: 106, code: "10000110010" }, // A: LF, B: j, C: 74, BandCode: 142211 + { ascii: 107, code: "11000010010" }, // A: VT, B: k, C: 75, BandCode: 241211 + { ascii: 108, code: "11001010000" }, // A: FF, B: l, C: 76, BandCode: 221114 + { ascii: 109, code: "11110111010" }, // A: CR, B: m, C: 77, BandCode: 413111 + { ascii: 110, code: "11000010100" }, // A: SO, B: n, C: 78, BandCode: 241112 + { ascii: 111, code: "10001111010" }, // A: SI, B: o, C: 79, BandCode: 134111 + { ascii: 112, code: "10100111100" }, // A: DLE, B: p, C: 80, BandCode: 111242 + { ascii: 113, code: "10010111100" }, // A: DC1, B: q, C: 81, BandCode: 121142 + { ascii: 114, code: "10010011110" }, // A: DC2, B: r, C: 82, BandCode: 121241 + { ascii: 115, code: "10111100100" }, // A: DC3, B: s, C: 83, BandCode: 114212 + { ascii: 116, code: "10011110100" }, // A: DC4, B: t, C: 84, BandCode: 124112 + { ascii: 117, code: "10011110010" }, // A: NAK, B: u, C: 85, BandCode: 124211 + { ascii: 118, code: "11110100100" }, // A: SYN, B: v, C: 86, BandCode: 411212 + { ascii: 119, code: "11110010100" }, // A: ETB, B: w, C: 87, BandCode: 421112 + { ascii: 120, code: "11110010010" }, // A: CAN, B: x, C: 88, BandCode: 421211 + { ascii: 121, code: "11011011110" }, // A: EM, B: y, C: 89, BandCode: 212141 + { ascii: 122, code: "11011110110" }, // A: SUB, B: z, C: 90, BandCode: 214121 + { ascii: 123, code: "11110110110" }, // A: ESC, B: {, C: 91, BandCode: 412121 + { ascii: 124, code: "10101111000" }, // A: FS, B: |, C: 92, BandCode: 111143 + { ascii: 125, code: "10100011110" }, // A: GS, B: }, C: 93, BandCode: 111341 + { ascii: 126, code: "10001011110" }, // A: RS, B: ~, C: 94, BandCode: 131141 + { ascii: 200, code: "10111101000" }, // A: US, B: DEL, C: 95, BandCode: 114113 + { ascii: 201, code: "10111100010" }, // A: FNC3, B: FNC3, C: 96, BandCode: 114311 + { ascii: 202, code: "11110101000" }, // A: FNC2, B: FNC2, C: 97, BandCode: 411113 + { ascii: 203, code: "11110100010" }, // A: SHIFT, B: SHIFT, C: 98, BandCode: 411311 + { ascii: 204, code: "10111011110" }, // A: CODEC, B: CODEC, C: 99, BandCode: 113141 + { ascii: 205, code: "10111101110" }, // A: CODEB, B: FNC4, C: CODEB, BandCode: 114131 + { ascii: 206, code: "11101011110" }, // A: FNC4, B: CODEA, C: CODEA, BandCode: 311141 + { ascii: 207, code: "11110101110" }, // A: FNCl, B: FNCl, C: FNCl, BandCode: 411131 + { ascii: 208, code: "11010000100" }, // A: StartA, B: StartA, C: StartA, BandCode: 211412 + { ascii: 209, code: "11010010000" }, // A: StartB, B: StartB, C: StartB, BandCode: 211214 + { ascii: 210, code: "11010011100" }, // A: StartC, B: StartC, C: StartC, BandCode: 211232 + { ascii: 211, code: "1100011101011" }, // A: Stop, B: Stop, C: Stop, BandCode: 2331112 +]; +const code128_ascii_to_id = code128_bp.reduce( + (acc, { ascii }, idx) => { + acc[ascii] = idx; + return acc; + }, + {} as Record, +); + +/** + * Converts a string to Code128B barcode + * @param data string to convert + * @returns string of Code128B barcode, it is a sequence of 0 and 1, representing a white or black stripe, respectively. + */ +export function code128b(data: string): string { + // Code128 allows only 232 characters, but we need to add start, stop, and checksum, so there are 229 characters left. + if (data.length > 229) throw new Error("Data too long for Code128B"); + + const result: string[] = []; + + result.push(code128_bp[104].code); // Start Code B + // Convert each character to Code128B + let checksum = 104; + for (let i = 0; i < data.length; i++) { + const id = code128_ascii_to_id[data.charCodeAt(i)]; + if (id === undefined) throw new Error("Invalid character in Code128B"); + result.push(code128_bp[id].code); + checksum += (i + 1) * id; + } + result.push(code128_bp[checksum % 103].code); // Checksum + result.push(code128_bp[106].code); // Stop + + return result.join(""); +} + + + +/** Check if browser makes some modifications to canvas when reading */ +export const detectAntiFingerprinting = () => { + const size = 32; + const color = [0, 127, 255, 255]; + + const canvas = document.createElement("canvas"); + canvas.height = size; + canvas.width = size; + + const ctx = canvas.getContext("2d"); + + if (ctx === null) return false; + + ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 1)`; + + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + + for (let i = 0; i < data.length; i += 1) { + if (data[i] !== color[i % 4]) { + canvas.remove(); + return true; + } + } + + canvas.remove(); + return false; +}; + + + +import { derived, writable } from "svelte/store"; +import type { TranslationKey, SupportedLanguage } from "$/locale"; +import { languageNames, langPack } from "$/locale"; +import { match as langMatch } from "@formatjs/intl-localematcher"; + +/** Check browser language and return supported language code. + * If language is not supported, "en" is returned. */ +const guessBrowserLanguage = (): SupportedLanguage => { + const fallback: SupportedLanguage = "en"; + const browserLang = navigator.language; + const supportedLangs = Object.keys(langPack).map((e) => e.replaceAll("_", "-")); + + try { + const nearestLang = langMatch([browserLang], supportedLangs, fallback); + return nearestLang.replaceAll("-", "_") as SupportedLanguage; + } catch (e) { + console.warn("Unable to detect language:", e); + return fallback; + } +}; + +export const locale = writable( + (localStorage.getItem("locale") as SupportedLanguage) ?? guessBrowserLanguage(), +); + +locale.subscribe((value: SupportedLanguage) => localStorage.setItem("locale", value)); + +export const tr = derived(locale, ($locale) => (key: TranslationKey) => { + const result = langPack[$locale] ? langPack[$locale][key] : undefined; + if (result === undefined || result === "") { + if ($locale !== "en") { + console.warn(`${key} of ${$locale} locale is not translated`); + } + return langPack.en[key]; + } + return result; +}); + +export const locales = languageNames; +export type { TranslationKey, SupportedLanguage } from "$/locale"; + + + +import * as fabric from "fabric"; +import { GRID_SIZE, OBJECT_DEFAULTS } from "$/defaults"; +import type { MoveDirection } from "$/types"; + +export class LabelDesignerUtils { + static async cloneSelection(canvas: fabric.Canvas): Promise { + const clonedList: fabric.FabricObject[] = []; + + const selection = canvas.getActiveObject(); + + if (selection === undefined) { + return; + } + + const selected: fabric.FabricObject[] = canvas.getActiveObjects(); + + for (const obj of selected) { + const cloned = await obj.clone(); + + if (selection instanceof fabric.ActiveSelection) { + cloned.left += selection.left + selection.width / 2; + cloned.top += selection.top + selection.height / 2; + } + + cloned.top += GRID_SIZE; + cloned.left += GRID_SIZE; + cloned.snapAngle = OBJECT_DEFAULTS.snapAngle; + + clonedList.push(cloned); + } + + canvas.add(...clonedList); + + const newSelection = new fabric.ActiveSelection(clonedList); + canvas.setActiveObject(newSelection); + } + + static moveSelection( + canvas: fabric.Canvas, + direction: MoveDirection, + ctrl?: boolean, + ) { + const selected: fabric.FabricObject[] = canvas.getActiveObjects(); + const amount = ctrl ? 1 : GRID_SIZE; + + selected.forEach((obj) => { + if (direction === "left") { + // round to fix inter-pixel positions + obj.left = Math.round(obj.left) - amount; + } else if (direction === "right") { + obj.left = Math.round(obj.left) + amount; + } else if (direction === "up") { + obj.top = Math.round(obj.top) - amount; + } else if (direction === "down") { + obj.top = Math.round(obj.top) + amount; + } + obj.setCoords(); + }); + canvas.requestRenderAll(); + } + + static deleteSelection(canvas: fabric.Canvas) { + const selected: fabric.FabricObject[] = canvas.getActiveObjects(); + selected.forEach((obj) => { + canvas.remove(obj); + }); + } + + static isAnyInputFocused(canvas: fabric.Canvas): boolean { + const focused: Element | null = document.activeElement; + + if ( + focused !== null && + (focused.tagName === "INPUT" || focused.tagName === "TEXTAREA") + ) { + return true; + } + const selected: fabric.FabricObject[] = canvas.getActiveObjects(); + const editing = selected.some( + (obj) => obj instanceof fabric.IText && obj.isEditing, + ); + + if (editing) { + return true; + } + + return false; + } +} + + + +export const copyImageData = (iData: ImageData): ImageData => { + return new ImageData(new Uint8ClampedArray(iData.data), iData.width, iData.height); +}; + +// Original code is taken from https://github.com/NielsLeenheer/CanvasDither +// (but it is has typescript definitions and Atkinson threshold) + +/** + * Change the image to blank and white using a simple threshold + * + * + * @param {object} image The imageData of a Canvas 2d context + * @param {number} threshold Threshold value (0-255) + * @return {object} The resulting imageData + * + */ +export const threshold = (image: ImageData, threshold: number): ImageData => { + for (let i = 0; i < image.data.length; i += 4) { + const luminance = image.data[i] * 0.299 + image.data[i + 1] * 0.587 + image.data[i + 2] * 0.114; + const value = luminance < threshold ? 0 : 255; + image.data.fill(value, i, i + 3); + } + + return image; +}; + +/** + * Change the image to blank and white using the Atkinson algorithm + * + * @param {object} image The imageData of a Canvas 2d context + * @param {number} threshold Threshold value (0-255) + * @return {object} The resulting imageData + * + */ +export const atkinson = (image: ImageData, threshold: number): ImageData => { + const src = image.data; + const dst = new Uint8ClampedArray(image.width * image.height); + + for (let l = 0, i = 0; i < src.length; l++, i += 4) { + dst[l] = src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114; + } + + for (let l = 0, i = 0; i < src.length; l++, i += 4) { + const value = dst[l] < threshold ? 0 : 255; + const error = Math.floor((dst[l] - value) / 8); + src.fill(value, i, i + 3); + + dst[l + 1] += error; + dst[l + 2] += error; + dst[l + image.width - 1] += error; + dst[l + image.width] += error; + dst[l + image.width + 1] += error; + dst[l + 2 * image.width] += error; + } + + return image; +}; + +/** + * Change the image to blank and white using the Bayer ordered dithering + * + * @param {object} image The imageData of a Canvas 2d context + * @param {number} threshold Threshold value (0-255) + * @return {object} The resulting imageData + * + */ +export const bayer = (image: ImageData, threshold: number): ImageData => { + const src = image.data; + const width = image.width; + + // Pre-calculated 8x8 Bayer matrix (normalized to 0-255) + const bayerMatrix = [ + [0, 191, 48, 239, 12, 203, 60, 251], + [128, 64, 176, 112, 140, 76, 188, 124], + [32, 223, 16, 207, 44, 235, 28, 219], + [160, 96, 144, 80, 172, 108, 156, 92], + [8, 199, 56, 247, 4, 195, 52, 243], + [136, 72, 184, 120, 132, 68, 180, 116], + [40, 231, 24, 215, 36, 227, 20, 211], + [168, 104, 152, 88, 164, 100, 148, 84] + ]; + + for (let i = 0; i < src.length; i += 4) { + const x = (i / 4) % width; + const y = Math.floor((i / 4) / width); + + const gray = src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114; + const bayerValue = bayerMatrix[y % 8][x % 8]; + const value = gray < threshold - bayerValue / 2 ? 0 : 255; + + src[i] = src[i + 1] = src[i + 2] = value; + } + + return image; +}; + +/** + * Invert image + * + * @param {object} image The imageData of a Canvas 2d context + * @return {object} The resulting imageData + * + */ +export const invert = (image: ImageData): ImageData => { + for (let i = 0; i < image.data.length; i += 4) { + const black = (image.data[i] + image.data[i + 1] + image.data[i + 2]) === 0; + image.data.fill(black ? 255 : 0, i, i + 3); + } + + return image; +}; + + + +import Toastify from "toastify-js"; +import { z } from "zod"; + +export class Toasts { + static error(e: any) { + console.error(e); + + Toastify({ + text: `${e}`, + gravity: "bottom", + duration: 5000, + className: "toast-danger", + }).showToast(); + } + + static message(text: string) { + Toastify({ + text, + gravity: "bottom", + duration: 5000, + className: "toast-info", + }).showToast(); + } + + static zodErrors(e: any, prefix: string) { + if (e instanceof z.ZodError) { + e.issues.forEach((i) => { + this.error(`${prefix} "${i.path.join("→")}" ${i.message}`); + }); + } + } +} + + + +import * as fabric from "fabric"; +import type { ExportedLabelTemplate, LabelProps } from "$/types"; + +export type UndoState = { undoDisabled: boolean; redoDisabled: boolean }; + +export class UndoRedo { + private readonly UNDO_MAX: number = 20; + + private buf: ExportedLabelTemplate[] = []; + private index: number = 0; + + public paused: boolean = false; + + public onLabelUpdate?: (data: ExportedLabelTemplate) => Promise; + public onStateUpdate?: (state: UndoState) => void; + + private updateState() { + this.onStateUpdate?.({ + undoDisabled: this.index === 0, + redoDisabled: this.index >= this.buf.length - 1, + }); + } + async undo() { + if (this.index > 0 && this.index < this.buf.length) { + await this.onLabelUpdate?.(this.buf[this.index - 1]); + this.index--; + } + this.updateState(); + } + + async redo() { + if (this.index < this.buf.length - 1) { + await this.onLabelUpdate?.(this.buf[this.index + 1]); + this.index++; + } + this.updateState(); + } + + push(fabricCanvas: fabric.Canvas, labelProps: LabelProps) { + if (this.paused) { + return; + } + + if (this.index !== this.buf.length - 1 && this.index > 0 && this.index <= this.buf.length) { + this.buf = this.buf.slice(0, this.index + 1); + } + + this.buf.push({ + label: labelProps, + canvas: fabricCanvas.toJSON(), + }); + + if (this.buf.length > this.UNDO_MAX) { + this.buf.shift(); + } + + this.index = this.buf.length - 1; + this.updateState(); + } +} + + + +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +const config = { + compilerOptions: { + runes: true + }, + preprocess: vitePreprocess(), +}; + +export default config; + + + +{ + "compilerOptions": { + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "allowJs": false, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": false, + "forceConsistentCasingInFileNames": true, + "useUnknownInCatchVariables": true, + "lib": ["dom", "dom.iterable", "es2022", "webworker"], + "baseUrl": "./", + "paths": { + "$/*": ["src/*"] + } + }, + "include": [ + "vite.config.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte" + ] +} + + + +FROM node:25-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ + +RUN npm ci + +COPY . . + +RUN npm run sv-check && npm run build + +FROM nginx:1.27-alpine AS server + +COPY --from=builder /app/dist/ /usr/share/nginx/html/ + +EXPOSE 80 + + + + + + + + + +{#if pdfImageSrc} +
+

Success! PDF Received:

+ PDF converted to image + +
+ +
+
+{/if} +
+ + + + +
+ Firmware flashing +
+ {#if fwProgress} + Uploading {fwProgress} + {:else} + To + + ver. + + + + {/if} +
+
+
+ + + + + + + + + + + + +
+ + +
+ +
+ + + + { + selectedBarcode?.set("scaleFactor", e.currentTarget.valueAsNumber ?? 1); + valueUpdated(); + }} /> +
+ + + +
+ + + + { + selectedBarcode?.set("fontSize", e.currentTarget.valueAsNumber ?? 12); + valueUpdated(); + }} /> +
+ + + + + +
+ + + + + + +{#if show} + +
+ {#each $userFonts as font (font.family)} +
+ {font.family} + +
+ {:else} + 👀 + {/each} +
+ +
+ +
+ {$tr("fonts.add")} + + + + + + +
+ + {#snippet footer()} +
+ {usedSpace} + {$tr("params.saved_labels.kb_used")} | + {$tr("fonts.gfonts")} +
+ {/snippet} +
+{/if} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{#if selectedObject instanceof fabric.Rect} +
+ + + + roundRadiusChanged(e.currentTarget.valueAsNumber)} /> +
+{/if} + +{#if selectedObject instanceof fabric.Rect || selectedObject instanceof fabric.Circle || selectedObject instanceof fabric.Line || selectedObject instanceof fabric.Polyline} +
+ + + + strokeWidthChanged(e.currentTarget.valueAsNumber)} /> +
+{/if} + +{#if selectedObject instanceof fabric.Rect || selectedObject instanceof fabric.Circle} +
+ + + + +
+{/if} + + +
+ + +import * as fabric from "fabric"; + +interface UniqueTextboxExtProps { + fontAutoSize: boolean; +} + +const TEXTBOX_PROPS: Array = ["fontAutoSize"]; + +export const textboxExtDefaultValues: Partial> = { + fontAutoSize: false, +}; + +export interface TextboxExtProps extends fabric.TextboxProps, UniqueTextboxExtProps {} +export interface SerializedTextboxExtProps extends fabric.SerializedTextboxProps, UniqueTextboxExtProps {} + +export class TextboxExt< + Props extends fabric.TOptions = Partial, + SProps extends SerializedTextboxExtProps = SerializedTextboxExtProps, + EventSpec extends fabric.ITextEvents = fabric.ITextEvents, + > + extends fabric.Textbox + implements UniqueTextboxExtProps +{ + declare fontAutoSize: boolean; + + private widthBeforeEditing?: number; + + constructor(text: string, options?: Props) { + super(text, options); + Object.assign(this, textboxExtDefaultValues); + this.setOptions(options); + + this.setControlsVisibility({ + mb: false, + mt: false, + }); + } + + /** Set text and reduce fontSize until text fits to the given width */ + setAndShrinkText(text: string, maxWidth: number, maxLines?: number) { + const linesLimit = maxLines ?? this._splitTextIntoLines(this.text).lines.length; + + let linesCount = this._splitTextIntoLines(text).lines.length; + + this.set({ text }); + + while ((linesCount > linesLimit || this.width > maxWidth) && this.fontSize > 2) { + this.fontSize -= 1; + this.set({ text, width: maxWidth }); + linesCount = this._splitTextIntoLines(text).lines.length; + } + } + + /** Reduce fontSize until text fits to the given width */ + shrinkText(maxWidth: number, maxLines: number) { + let linesCount = this._splitTextIntoLines(this.text).lines.length; + + while ((linesCount > maxLines || this.width > maxWidth) && this.fontSize > 2) { + this.fontSize -= 1; + this.set({ width: maxWidth }); + linesCount = this._splitTextIntoLines(this.text).lines.length; + } + } + + override enterEditingImpl() { + super.enterEditingImpl(); + this.widthBeforeEditing = this.width; + } + + override exitEditingImpl() { + super.exitEditingImpl(); + this.widthBeforeEditing = undefined; + } + + override updateFromTextArea(): void { + super.updateFromTextArea(); + + if (this.widthBeforeEditing !== undefined && this.fontAutoSize) { + const lines = this.text.split("\n").length; + this.shrinkText(this.widthBeforeEditing, lines); + } + } + + override toObject, keyof SProps>, K extends keyof T = never>( + propertiesToInclude: K[] = [], + ): Pick & SProps { + return super.toObject([...propertiesToInclude, ...TEXTBOX_PROPS] as (keyof T)[]); + } +} + + + +import "$/styles/style.scss"; +import "@popperjs/core"; +import "toastify-js/src/toastify.css"; +import "bootstrap/js/dist/dropdown"; +import "bootstrap/js/dist/collapse"; +import App from "$/App.svelte"; +import { mount } from "svelte"; +import { configureFabric } from "$/defaults"; + +configureFabric(); + +const app = mount(App, { + target: document.getElementById("app")!, +}); + +export default app; + + + +import { + AutomationPropsSchema, + ExportedLabelTemplateSchema, + FabricJsonSchema, + LabelPresetSchema, + LabelPropsSchema, + PreviewPropsSchema, + type AutomationProps, + type ConnectionType, + type ExportedLabelTemplate, + type LabelPreset, + type LabelProps, + type PreviewProps, +} from "$/types"; +import { z } from "zod"; +import { FileUtils } from "$/utils/file_utils"; +import { get, writable, type Updater, type Writable } from "svelte/store"; + +/** Writable store, value is persisted to localStorage */ +export function writablePersisted( + key: string, + schema: z.ZodType, + initialValue: T, +): Writable { + const wr = writable(initialValue); + + try { + const val = LocalStoragePersistence.loadAndValidateObject(key, schema); + if (val === null) { + wr.set(initialValue); + } else { + wr.set(val); + } + } catch { + wr.set(initialValue); + } + + return { + subscribe: wr.subscribe, + + set: (value: T) => { + LocalStoragePersistence.validateAndSaveObject(key, value, schema); + wr.set(value); + }, + + update: (updater: Updater) => { + const newValue: T = updater(get(wr)); + LocalStoragePersistence.validateAndSaveObject(key, newValue, schema); + wr.set(newValue); + }, + }; +} + +export class LocalStoragePersistence { + /** Result in kilobytes */ + static usedSpace(): number { + let total = 0; + Object.keys(localStorage).forEach((key) => { + total += (localStorage[key].length + key.length) * 2; + }); + return Math.floor(total / 1024); + } + + static saveObject(key: string, data: any) { + if (data === null || data === undefined) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, JSON.stringify(data)); + } + static loadObject(key: string): any { + const data = localStorage.getItem(key); + if (data !== null) { + try { + return JSON.parse(data); + } catch (e) { + console.log(e); + } + } + return null; + } + + /** + * @throws {z.ZodError} + */ + static loadAndValidateObject(key: string, schema: z.ZodType) { + const data = this.loadObject(key); + + if (data === null) { + return null; + } + + return schema.parse(data); + } + + static validateAndSaveObject( + key: string, + data: any, + schema: z.ZodType, + ): void { + if (data === null || data === undefined) { + this.saveObject(key, data); + return; + } + + const obj = schema.parse(data); + this.saveObject(key, obj); + } + + /** + * @throws {z.ZodError} + */ + static loadLastLabelProps(): LabelProps | null { + return this.loadAndValidateObject("last_label_props", LabelPropsSchema); + } + + /** + * @throws {z.ZodError} + */ + static saveLastLabelProps(labelData: LabelProps) { + this.validateAndSaveObject("last_label_props", labelData, LabelPropsSchema); + } + + static createUidForLabel(label: ExportedLabelTemplate): string { + const basename = `saved_label_${label.timestamp}`; + let counter = 0; + + while (`${basename}_${counter}` in localStorage) { + counter++; + } + + return `${basename}_${counter}`; + } + + static saveLabels(labels: ExportedLabelTemplate[]): { + zodErrors: z.ZodError[]; + otherErrors: Error[]; + } { + const zodErrors: z.ZodError[] = []; + const otherErrors: Error[] = []; + + Object.keys(localStorage).forEach((key) => { + if (key.startsWith("saved_label")) { + localStorage.removeItem(key); + } + }); + + labels.forEach((label) => { + try { + if (label.timestamp === undefined) { + label.timestamp = FileUtils.timestamp(); + } + + const basename = `saved_label_${label.timestamp}`; + let counter = 0; + + while (`${basename}_${counter}` in localStorage) { + counter++; + } + + this.validateAndSaveObject( + this.createUidForLabel(label), + label, + ExportedLabelTemplateSchema.omit({ id: true }), + ); + } catch (e) { + if (e instanceof z.ZodError) { + zodErrors.push(e); + } + if (e instanceof Error) { + otherErrors.push(e); + } + } + }); + return { zodErrors, otherErrors }; + } + + /** + * @throws {z.ZodError} + */ + static loadLabels(): ExportedLabelTemplate[] { + const legacyLabel = this.loadAndValidateObject( + "saved_canvas_props", + LabelPropsSchema, + ); + const legacyCanvas = this.loadAndValidateObject( + "saved_canvas_data", + FabricJsonSchema, + ); + const items: ExportedLabelTemplate[] = []; + + if (legacyLabel !== null && legacyCanvas !== null) { + localStorage.removeItem("saved_canvas_props"); + localStorage.removeItem("saved_canvas_data"); + const item: ExportedLabelTemplate = { + label: legacyLabel, + canvas: legacyCanvas, + timestamp: FileUtils.timestamp(), + }; + this.validateAndSaveObject( + `saved_label_${item.timestamp}`, + item, + ExportedLabelTemplateSchema, + ); + } + + Object.keys(localStorage) + .sort() + .forEach((key) => { + if (key.startsWith("saved_label")) { + try { + const item = this.loadAndValidateObject( + key, + ExportedLabelTemplateSchema, + ); + if (item != null) { + item.id = key; + items.push(item); + } + } catch (e) { + console.error(e); + } + } + }); + + return items; + } + + /** + * @throws {z.ZodError} + */ + static savePreviewProps(props: PreviewProps) { + this.validateAndSaveObject( + "saved_preview_props", + props, + PreviewPropsSchema, + ); + } + + /** + * @throws {z.ZodError} + */ + static loadSavedPreviewProps(): PreviewProps | null { + return this.loadAndValidateObject( + "saved_preview_props", + PreviewPropsSchema, + ); + } + + /** + * @throws {z.ZodError} + */ + static saveLabelPresets(presets: LabelPreset[]) { + this.validateAndSaveObject( + "label_presets", + presets, + z.array(LabelPresetSchema), + ); + } + + /** + * @throws {z.ZodError} + */ + static loadLabelPresets(): LabelPreset[] | null { + const presets = this.loadAndValidateObject( + "label_presets", + z.array(LabelPresetSchema), + ); + return presets === null || presets.length === 0 ? null : presets; + } + + static loadLastConnectionType(): ConnectionType | null { + const value = localStorage.getItem("connection_type"); + if (value === null || !["bluetooth", "serial"].includes(value)) { + return null; + } + return value as ConnectionType; + } + + static saveLastConnectionType(value: ConnectionType) { + localStorage.setItem("connection_type", value); + } + + /** + * @throws {z.ZodError} + */ + static saveAutomation(value?: AutomationProps) { + this.validateAndSaveObject("automation", value, AutomationPropsSchema); + } + + /** + * @throws {z.ZodError} + */ + static loadAutomation(): AutomationProps | null { + return this.loadAndValidateObject("automation", AutomationPropsSchema); + } + + /** + * @throws {z.ZodError} + */ + static saveDefaultTemplate(value?: ExportedLabelTemplate) { + this.validateAndSaveObject( + "default_template", + value, + ExportedLabelTemplateSchema.omit({ id: true }), + ); + } + + /** + * @throws {z.ZodError} + */ + static loadDefaultTemplate(): ExportedLabelTemplate | null { + return this.loadAndValidateObject( + "default_template", + ExportedLabelTemplateSchema, + ); + } + + static hasCustomDefaultTemplate(): boolean { + return "default_template" in localStorage; + } + + /** + * @throws {z.ZodError} + */ + static saveCachedFonts(fonts: string[]) { + this.validateAndSaveObject("font_cache", fonts, z.array(z.string())); + } + + /** + * @throws {z.ZodError} + */ + static loadCachedFonts(): string[] { + return this.loadAndValidateObject("font_cache", z.array(z.string())) ?? []; + } +} + + + +/// +/// +declare const __APP_VERSION__: string; +declare const __APP_COMMIT__: string; +declare const __BUILD_DATE__: string; + +// not declared in ts lib, experimental feature +declare type FontData = { + readonly family: string; + readonly fullName: string; + readonly postscriptName: string; + readonly style: string; +}; + +declare function queryLocalFonts(): Promise>; + + + +android/capacitor.settings.gradle eol=lf +android/app/capacitor.build.gradle eol=lf + + + +.idea/ +node_modules/ +.vscode/ +*.map +.DS_Store +.sourcemaps +www/ + + + +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml + + + +/build/* +!/build/.npmkeep + + + +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-community-bluetooth-le') + implementation project(':capacitor-filesystem') + implementation project(':capacitor-share') + implementation project(':capacitor-splash-screen') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} + + + +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + + + +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-community-bluetooth-le' +project(':capacitor-community-bluetooth-le').projectDir = new File('../node_modules/@capacitor-community/bluetooth-le/android') + +include ':capacitor-filesystem' +project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') + +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') + +include ':capacitor-splash-screen' +project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') + + + +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + + + +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' + + + +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml + + + + + + + + com.apple.security.app-sandbox + + com.apple.security.device.bluetooth + + com.apple.security.network.client + + + + + + +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} + + + + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + NiimBlues + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + $(PRODUCT_NAME) uses bluetooth to communicate with printer + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + + + + +## NiimBlue standalone app + +Install dependencies + +```bash +cd .. +npm i +cd capacitor +npm i +``` + +Build niimblue static files + +```bash +npm run build-www +``` + +# Android + +Run debug build on android device (adb) + +```bash +npm run run-android +``` + +Or build release apk (`apksigner` should be in your PATH): + +```bash +export KEYSTORE_PATH=/path/to/keystore.jks +export KEYSTORE_ALIAS=your_alias_name +export KEYSTORE_PASSWORD=pa$$word +export KEYSTORE_ALIAS_PASSWORD=pa$$word + +npm run build-android +``` + +To see console, go to `chrome:inspect/#devices` on desktop chrome browser and select niimblue on your device. + +To get prebuilt apk, see the latest artifact in [build-android-app](https://github.com/MultiMote/niimblue/actions/workflows/build-android-app.yml) Actions task. + +# iOS (experimental) + +Run debug build on iPhone Simulator + +```bash +npm run run-ios +``` + +Run on device, + +```bash +npm run build-ios +``` + +**iOS**: Unlike Android (and also Cordova), Capacitor lacks ability to pass `DEVELOPMENT_TEAM` as environment variable. + +In order to run on actual device, you'll need to set your own DEVELOPMENT_TEAM within Xcode under `Signing & Capabilities`. + +Free (limited) developer account is also available. +[Click here for more details](https://developer.apple.com/support/compare-memberships/) + +**macOS (Catalyst)**: Choose Mac Catalyst. + +Note: macOS doesn't need codesign to run on local machine. + + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +www +version.json + + + +{ + "name": "niimblues", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "tauri": "tauri", + "build-www": "cd ../.. && vite build --outDir ./standalone-apps/tauri-windows/www" + }, + "devDependencies": { + "@tauri-apps/cli": "^2" + } +} + + + +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas + + + +fn main() { + tauri_build::build() +} + + + +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default" + ] +} + + + +[package] +name = "niimblues" +version = "1.0.0" +description = "NiimBlue standalone" +authors = ["MultiMote"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "niimblues_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" +tauri-plugin-window-state = "2.0.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + + + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_window_state::Builder::default().build()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + + + +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + niimblues_lib::run() +} + + + +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "niimblues", + "version": "1.0.0", + "identifier": "ru.mmote.niimblues", + "build": { + "frontendDist": "../www" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "NiimBlues", + "width": 1280, + "height": 720 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false + } +} + + + +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { resolve } from "node:path"; + +const getDate = (): string => { + const date = new Date(); + const fmt = (n: number) => (n > 9 ? n : `0${n}`); + return `${date.getFullYear()}-${fmt(date.getMonth() + 1)}-${fmt(date.getDate())}`; +}; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [svelte()], + define: { + __APP_VERSION__: JSON.stringify(process.env.npm_package_version), + __APP_COMMIT__: JSON.stringify(process.env.COMMIT_HASH), + __BUILD_DATE__: JSON.stringify(getDate()), + }, + optimizeDeps: { + include: ["@mmote/niimbluelib"], // Fix browser error when using `npm link @mmote/niimbluelib` + }, + resolve: { + preserveSymlinks: true, // Fix build error when using `npm link @mmote/niimbluelib` + alias: { + $: resolve(__dirname, "./src") + }, + }, + build: { + rollupOptions: { + output: { + manualChunks: (id: string) => { + if (id.endsWith(".css") || id.endsWith(".scss")) { + return "style"; + } + + if (id.includes("node_modules")) { + if (id.includes("fabric")) { + return "lib.2.fabric"; + } else if ( + id.includes("@capacitor/filesystem") || + id.includes("@capacitor/share") + ) { + return "lib.2.cap"; + } else if (id.includes("zod")) { + return "lib.2.zod"; + } else if (id.includes("@mmote/niimbluelib")) { + return "lib.2.niim"; + } else if (id.includes("pdfjs-dist")) { + return "lib.2.pdf"; + } + + return "lib.1.other"; + } + return null; + }, + chunkFileNames: () => { + return "assets/[name].[hash].js"; + }, + }, + }, + }, +}); + + + +# Contributing + +Thank you for your interest in contributing to NiimBlue! + +## Key points + +- Use [dev](https://github.com/MultiMote/niimblue/tree/dev) branch as the base branch for your pull requests. It contains most recent changes. Do not submit pull requests directly to `main` to avoid conflicts. + +- Do not submit "Monster" pull requests like [this](https://github.com/MultiMote/niimblue/pull/101). It's very difficult to review. Instead, please break down your changes into smaller, focused pull requests. + +- Do not submit "format all files" pull requests. + +- Avoid Mobile-Only UI changes. If you want to make UI changes, please make sure they work well on both desktop and mobile. + +- Run `npm run sv-check` and `npm run lint` before submitting pull request. + + + + + + + + + + + + +
+ {$tr("debug.packet_interval.help")} +
+ +
+ + ms + +
+ +
+ {$tr("debug.page_delay.help")} +
+ +
+ + ms + +
+
+
+ + + + + + +
+ + + + +
+ +
+ + + + { + const val = parseInt(e.currentTarget.value); + if (!isNaN(val) && val >= 0 && val <= maxId) { + selectedArUco?.set("markerId", val); + valueUpdated(); + } + }} /> +
+ + +
+ + + + + + + + + + + + + + + + + + +
+
+
+

+ NiimBlue{isStandalone ? "s" : ""} +

+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ + + +{#if debugStuffShow} + +{/if} + + +
+ + +import * as fabric from "fabric"; +import { OBJECT_SIZE_DEFAULTS } from "$/defaults"; +import { CanvasUtils } from "$/utils/canvas_utils"; + +export type ArUcoDictionary = "4x4" | "5x5" | "6x6"; + +interface DictInfo { + size: number; + bytesPerMarker: number; + count: number; + data: number[][]; +} + +// first 50 markers from OpenCV's predefined dictionaries (via arucogen) +const DICTIONARIES: Record = { + "4x4": { + size: 4, + bytesPerMarker: 2, + count: 50, + data: [ + [181, 50], [15, 154], [51, 45], [153, 70], [84, 158], [121, 205], + [158, 46], [196, 242], [254, 218], [207, 86], [249, 145], [17, 167], + [14, 183], [42, 15], [36, 177], [38, 62], [70, 101], [102, 0], + [108, 94], [118, 175], [134, 139], [176, 43], [204, 213], [221, 130], + [254, 71], [148, 113], [172, 228], [165, 84], [33, 35], [52, 111], + [68, 21], [87, 178], [158, 207], [240, 203], [8, 174], [9, 41], + [24, 117], [4, 255], [13, 246], [28, 90], [23, 24], [42, 40], + [50, 140], [56, 178], [36, 232], [46, 235], [45, 63], [75, 100], + [80, 46], [80, 19], + ], + }, + "5x5": { + size: 5, + bytesPerMarker: 4, + count: 50, + data: [ + [162, 217, 94, 0], [14, 3, 115, 0], [215, 135, 110, 1], + [129, 202, 251, 1], [215, 90, 146, 0], [234, 4, 22, 1], + [105, 235, 246, 0], [113, 10, 53, 1], [134, 176, 153, 0], + [152, 159, 210, 1], [158, 119, 1, 1], [209, 109, 96, 0], + [243, 21, 136, 1], [47, 56, 179, 0], [254, 126, 84, 0], + [40, 241, 191, 1], [75, 211, 172, 0], [95, 81, 55, 1], + [123, 38, 226, 0], [131, 14, 244, 0], [150, 237, 58, 1], + [168, 114, 32, 0], [181, 134, 80, 1], [93, 9, 111, 0], + [206, 104, 17, 1], [210, 204, 185, 0], [225, 231, 69, 1], + [17, 33, 35, 0], [29, 203, 57, 0], [18, 17, 29, 1], + [19, 155, 183, 0], [27, 68, 57, 1], [32, 104, 103, 0], + [37, 85, 100, 0], [35, 33, 221, 0], [61, 55, 245, 0], + [76, 197, 86, 0], [65, 104, 128, 1], [77, 86, 142, 1], + [67, 30, 57, 0], [86, 148, 18, 1], [82, 151, 207, 0], + [108, 36, 251, 1], [97, 132, 236, 1], [109, 63, 24, 1], + [116, 177, 61, 0], [116, 220, 203, 1], [124, 164, 3, 0], + [122, 200, 146, 1], [123, 91, 235, 1], + ], + }, + "6x6": { + size: 6, + bytesPerMarker: 5, + count: 50, + data: [ + [30, 61, 216, 42, 6], [14, 251, 163, 137, 1], [21, 144, 126, 172, 13], + [201, 27, 48, 105, 14], [214, 7, 214, 225, 5], [216, 232, 224, 230, 8], + [66, 104, 180, 31, 5], [136, 165, 15, 41, 10], [48, 125, 82, 79, 13], + [60, 47, 52, 179, 12], [69, 223, 199, 78, 3], [72, 216, 91, 37, 7], + [113, 5, 88, 252, 6], [134, 220, 250, 208, 7], [141, 114, 169, 63, 6], + [162, 184, 157, 205, 14], [9, 253, 30, 156, 4], [21, 77, 189, 24, 15], + [48, 10, 49, 14, 2], [72, 7, 239, 175, 13], [86, 223, 17, 219, 6], + [102, 136, 50, 116, 12], [118, 232, 203, 120, 1], [154, 83, 217, 207, 3], + [169, 203, 132, 2, 4], [198, 117, 73, 73, 0], [193, 210, 136, 148, 1], + [231, 72, 8, 82, 11], [234, 47, 202, 132, 8], [233, 99, 183, 123, 1], + [250, 54, 101, 42, 15], [6, 91, 255, 123, 13], [5, 65, 215, 45, 6], + [12, 247, 36, 106, 2], [19, 56, 163, 158, 11], [21, 168, 147, 231, 4], + [58, 65, 126, 233, 14], [79, 17, 226, 108, 0], [83, 13, 182, 210, 0], + [88, 155, 250, 227, 4], [100, 9, 232, 160, 11], [96, 83, 122, 137, 1], + [97, 89, 6, 155, 10], [107, 255, 120, 215, 11], [112, 173, 150, 164, 15], + [117, 132, 111, 113, 10], [122, 149, 25, 47, 12], [134, 9, 118, 10, 10], + [138, 45, 68, 195, 15], [147, 235, 120, 177, 4], + ], + }, +}; + +function decodeBits(dict: ArUcoDictionary, markerId: number): number[][] { + const info = DICTIONARIES[dict]; + if (markerId < 0 || markerId >= info.count) return []; + + const bytes = info.data[markerId]; + const bits: number[] = []; + const bitsCount = info.size * info.size; + + for (const byte of bytes) { + const remaining = bitsCount - bits.length; + const start = Math.min(7, remaining - 1); + for (let i = start; i >= 0; i--) { + bits.push((byte >> i) & 1); + } + } + + const grid: number[][] = []; + for (let r = 0; r < info.size; r++) { + grid.push(bits.slice(r * info.size, (r + 1) * info.size)); + } + return grid; +} + +export const arUcoDefaultValues: Partial> = { + dictionary: "4x4", + markerId: 5, + stroke: "#000000", + fill: "#ffffff", + ...OBJECT_SIZE_DEFAULTS, +}; + +interface UniqueArUcoProps { + dictionary: ArUcoDictionary; + markerId: number; +} + +export interface ArUcoMarkerProps extends fabric.FabricObjectProps, UniqueArUcoProps {} +export interface SerializedArUcoMarkerProps extends fabric.SerializedObjectProps, UniqueArUcoProps {} + +const ARUCO_PROPS = ["dictionary", "markerId"] as const; + +export class ArUcoMarker< + Props extends fabric.TOptions = Partial, + SProps extends SerializedArUcoMarkerProps = SerializedArUcoMarkerProps, + EventSpec extends fabric.ObjectEvents = fabric.ObjectEvents, + > + extends fabric.FabricObject + implements ArUcoMarkerProps +{ + static override readonly type = "ArUcoMarker"; + + declare dictionary: ArUcoDictionary; + declare markerId: number; + + constructor(options?: Props) { + super(); + Object.assign(this, arUcoDefaultValues); + this.setOptions(options); + this.lockScalingFlip = true; + this.setControlsVisibility({ + ml: false, + mt: false, + mr: false, + mb: false, + tl: false, + tr: false, + bl: false, + }); + } + + override _set(key: string, value: any): this { + super._set(key, value); + if (key === "dictionary" || key === "markerId") { + this.dirty = true; + } + return this; + } + + override _render(ctx: CanvasRenderingContext2D): void { + const grid = decodeBits(this.dictionary, this.markerId); + if (grid.length === 0) { + CanvasUtils.renderError(ctx, this.width, this.height); + super._render(ctx); + return; + } + + const innerSize = DICTIONARIES[this.dictionary].size; + const totalCells = innerSize + 2; // +2 for border + const cellSize = Math.floor(this.width / totalCells); + const markerWidth = cellSize * totalCells; + + if (cellSize < 1 || markerWidth > this.width) { + CanvasUtils.renderError(ctx, this.width, this.height); + super._render(ctx); + return; + } + + ctx.save(); + ctx.translate(-Math.floor(markerWidth / 2), -Math.floor(markerWidth / 2)); + ctx.translate(-0.5, -0.5); // blurry rendering fix + + // black background (border + black cells) + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, markerWidth, markerWidth); + + // white cells — build a single path to avoid anti-aliasing seams between adjacent cells + ctx.fillStyle = "white"; + ctx.beginPath(); + for (let r = 0; r < innerSize; r++) { + for (let c = 0; c < innerSize; c++) { + if (grid[r][c] === 1) { + ctx.rect((c + 1) * cellSize, (r + 1) * cellSize, cellSize, cellSize); + } + } + } + ctx.fill(); + + ctx.restore(); + super._render(ctx); + } + + override toObject(propertiesToInclude: any[] = []) { + return super.toObject([...ARUCO_PROPS, ...propertiesToInclude]); + } +} + +fabric.classRegistry.setClass(ArUcoMarker, "ArUcoMarker"); + +export default ArUcoMarker; + + + +import * as fabric from "fabric"; +import { code128b, ean13 } from "$/utils/barcode"; +import { CanvasUtils } from "$/utils/canvas_utils"; +import { OBJECT_DEFAULTS_TEXT } from "$/defaults"; + +const EAN13_LONG_BAR_INDEXES: Set = new Set([0, 1, 2, 45, 46, 47, 48, 49, 92, 93, 94]); +export type BarcodeCoding = "EAN13" | "CODE128B"; + +export const barcodeDefaultValues: Partial> = { + text: "", + encoding: "EAN13", + printText: true, + scaleFactor: 1, + fontSize: 12, + fontFamily: OBJECT_DEFAULTS_TEXT.fontFamily, +}; + +interface UniqueBarcodeProps { + text: string; + encoding: BarcodeCoding; + printText: boolean; + scaleFactor: number; + fontSize: number; + fontFamily: string; +} +export interface BarcodeProps extends fabric.FabricObjectProps, UniqueBarcodeProps {} +export interface SerializedBarcodeProps extends fabric.SerializedObjectProps, UniqueBarcodeProps {} +const BARCODE_PROPS = ["text", "encoding", "printText", "scaleFactor", "fontSize", "fontFamily"] as const; + +export class Barcode< + Props extends fabric.TOptions = Partial, + SProps extends SerializedBarcodeProps = SerializedBarcodeProps, + EventSpec extends fabric.ObjectEvents = fabric.ObjectEvents, + > + extends fabric.FabricObject + implements BarcodeProps +{ + static override type = "Barcode"; + + /** + * Barcode text + * @type string + * @default "" + */ + declare text: string; + /** + * Barcode encoding + * @type BarcodeCoding + * @default "EAN13" + */ + declare encoding: BarcodeCoding; + /** + * Print text + * @type boolean + * @default true + */ + declare printText: boolean; + /** + * Scale factor + * @type number + * @default 1 + */ + declare scaleFactor: number; + /** + * Font size + * @type number + * @default 12 + */ + declare fontSize: number; + /** + * Font family + * @type string + * @default "Noto Sans Variable" + */ + declare fontFamily: string; + + private barcodeEncoded: string = ""; + private displayText: string = ""; + + private error: boolean = false; + + constructor(options?: Props) { + super(); + Object.assign(this, barcodeDefaultValues); + const { text, ...other } = options ?? {}; + this.setOptions(other); // Must be set separately because the encoding needs to be set first + this.set("text", text); + this.setControlsVisibility({ + tl: false, + tr: false, + bl: false, + br: false, + ml: false, + mr: false, + mtr: false, + }); + this.objectCaching = false; + this._createBandCode(); + } + + override _set(key: string, value?: any): this { + super._set(key, value); + + if (key === "text" || key == "encoding") { + this._createBandCode(); + } + + if (this.barcodeEncoded && (BARCODE_PROPS.includes(key as any) || key == "canvas")) { + const letterWidth = this._measureLetterWidth(); + let barcodeWidth = (this.scaleFactor ?? 1) * this.barcodeEncoded.length; + + if (this.encoding === "EAN13") { + barcodeWidth += letterWidth * 2; // side margins + } + super.set("width", barcodeWidth); + this.setCoords(); + } + + return this; + } + + _createBandCode() { + try { + this.error = false; + if (this.encoding === "EAN13") { + const { text, bandcode } = ean13(this.text); + this.displayText = text; + this.barcodeEncoded = bandcode; + } else { + this.displayText = this.text; + this.barcodeEncoded = code128b(this.text); + } + } catch (e) { + console.error(e); + this.error = true; + } + } + + _getFont(): string { + return `bold ${this.fontSize}px ${this.fontFamily}`; + } + + // parent canvas is needed for this operation + _measureLetterWidth(): number { + const ctx = this.canvas?.getContext(); + let w = 0; + + if (ctx !== undefined) { + ctx.save(); + ctx.font = this._getFont(); + w = ctx.measureText("0").width; + ctx.restore(); + } + return Math.ceil(w); + } + + override _render(ctx: CanvasRenderingContext2D) { + if (this.error) { + CanvasUtils.renderError(ctx, this.width, this.height); + super._render(ctx); + return; + } + + if (this.barcodeEncoded === "") { + super._render(ctx); + return; + } + + const letterWidth = this._measureLetterWidth(); + + ctx.save(); + ctx.translate(-this.width / 2, -this.height / 2); // make top-left origin + ctx.translate(0.5, 0.5); // blurry rendering fix + + ctx.font = this._getFont(); + ctx.textBaseline = "bottom"; + + const longBarHeight = this.height; + let shortBarHeight = this.height; + const barcodeStartPos = this.encoding === "EAN13" ? letterWidth : 0; + + if (this.printText) { + shortBarHeight -= this.fontSize * 1.2; + } else if (this.encoding === "EAN13") { + shortBarHeight -= 8; + } + + let blackStartPosition = -1; + let blackCount = 0; + let isLongBar = false; + + // render barcode + for (let i = 0; i < this.barcodeEncoded.length; i++) { + const isBlack = this.barcodeEncoded[i] === "1"; + const xPos = barcodeStartPos + i * this.scaleFactor; + + if (isBlack) { + blackCount++; + + if (blackStartPosition == -1) { + blackStartPosition = xPos; + } + + if (this.encoding === "EAN13" && EAN13_LONG_BAR_INDEXES.has(i)) { + isLongBar = true; + } + + if (blackStartPosition != -1 && i === this.barcodeEncoded.length - 1) { + // last index + ctx.fillRect( + blackStartPosition, + 0, + this.scaleFactor * blackCount, + isLongBar ? longBarHeight : shortBarHeight, + ); + } + } else { + ctx.fillRect(blackStartPosition, 0, this.scaleFactor * blackCount, isLongBar ? longBarHeight : shortBarHeight); + blackStartPosition = -1; + blackCount = 0; + isLongBar = false; + } + } + + // render text + if (this.printText) { + if (this.encoding === "EAN13") { + const parts = [this.displayText[0], this.displayText.slice(1, 7), this.displayText.slice(7, 13), ">"]; + const midPartWidth = 40; + const longBars1End = 4; + const longBars2End = 50; + + ctx.fillText(parts[0], 0, this.height); // first digit + + CanvasUtils.equalSpacingFillText( + ctx, + parts[1], + letterWidth + longBars1End * this.scaleFactor, + this.height, + midPartWidth * this.scaleFactor, + ); // part 1 + + CanvasUtils.equalSpacingFillText( + ctx, + parts[2], + letterWidth + longBars2End * this.scaleFactor, + this.height, + midPartWidth * this.scaleFactor, + ); // part 2 + + ctx.fillText(parts[3], this.width - letterWidth, this.height); // last digit + } else { + CanvasUtils.equalSpacingFillText(ctx, this.displayText, barcodeStartPos, this.height, this.width); + } + } + + ctx.restore(); + + super._render(ctx); + } + + override toObject(propertiesToInclude: any[] = []) { + return super.toObject([...BARCODE_PROPS, ...propertiesToInclude]); + } +} + +fabric.classRegistry.setClass(Barcode, "Barcode"); + +export default Barcode; + + + +import { Utils } from "@mmote/niimbluelib"; + +// copied from node_modules/qrcode-generator/dist/qrcode_UTF8.mjs (not sure how to import it from TS) +export const toUTF8Array = (str: string): number[] => { + const utf8 = []; + for (let i = 0; i < str.length; i++) { + let charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), 0x80 | (charcode & 0x3f)); + } else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), 0x80 | ((charcode >> 6) & 0x3f), 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff)); + utf8.push( + 0xf0 | (charcode >> 18), + 0x80 | ((charcode >> 12) & 0x3f), + 0x80 | ((charcode >> 6) & 0x3f), + 0x80 | (charcode & 0x3f), + ); + } + } + return utf8; +}; + +export const stringToBytes = (str: string): number[] => { + if (str.startsWith("hex:")) { + const input = str.slice(4).replaceAll(" ", "").toLowerCase(); + + if (input.length % 2 !== 0 || !/^[a-f0-9]+$/.test(input)) { + throw new Error("Invalid hex input"); + } + + const buf = Utils.hexToBuf(input); + return Array.from(buf); + } + + return toUTF8Array(str); +}; + + + +apply plugin: 'com.android.application' + +android { + namespace = "ru.mmote.niimblues" + compileSdk = rootProject.ext.compileSdkVersion + + def appVersionCode = Integer.valueOf(System.getenv("APP_VERSION_CODE") ?: "1") + + defaultConfig { + applicationId "ru.mmote.niimblues" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode (50 + appVersionCode) // Last GH actions build + versionName "0.1.${appVersionCode}" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} + + + +package ru.mmote.niimblues; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.pdf.PdfRenderer; +import android.net.Uri; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.util.Base64; +import android.util.Log; + +import com.getcapacitor.BridgeActivity; + +import java.io.ByteArrayOutputStream; + +public class MainActivity extends BridgeActivity { + + @Override + public void onResume() { + super.onResume(); + handleIntent(getIntent()); + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIntent(intent); + } + + private void handleIntent(Intent intent) { + // 1. Check if the app was opened via the "Share" menu with a PDF + if (Intent.ACTION_SEND.equals(intent.getAction()) && "application/pdf".equals(intent.getType())) { + Uri pdfUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (pdfUri != null) { + try { + // 2. Open the PDF file natively in Android + ParcelFileDescriptor fd = getContentResolver().openFileDescriptor(pdfUri, "r"); + PdfRenderer renderer = new PdfRenderer(fd); + + // Grab the first page (Page 0) + PdfRenderer.Page page = renderer.openPage(0); + + // 3. Convert the PDF page to a Bitmap Image + // The B1 printer is 203 DPI (roughly 400 pixels wide for standard labels) + int width = 400; + int height = (int) (width * ((float) page.getHeight() / page.getWidth())); + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + + // Fill the background with white (otherwise it might be transparent/black) + bitmap.eraseColor(Color.WHITE); + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_PRINT); + + // 4. Compress the image to a Base64 String so JavaScript can read it + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); + byte[] imageBytes = baos.toByteArray(); + String base64Image = "data:image/png;base64," + Base64.encodeToString(imageBytes, Base64.NO_WRAP); + + // 5. Inject the image into the Javascript Frontend + if (bridge != null && bridge.getWebView() != null) { + String js = "window.dispatchEvent(new CustomEvent('pdfReceived', { detail: '" + base64Image + "' }));"; + bridge.getWebView().evaluateJavascript(js, null); + } + + // Clean up memory + page.close(); + renderer.close(); + fd.close(); + + // Remove the intent so it doesn't process twice + setIntent(new Intent()); + + } catch (Exception e) { + Log.e("NiimBlue PDF", "Error converting PDF to Image", e); + } + } + } + } +} + + + +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + + + +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} + + + +import { CapacitorConfig } from "@capacitor/cli"; + +const config: CapacitorConfig = { + appId: "ru.mmote.niimblues", + appName: "NiimBlues", + webDir: "../../dist", + plugins: { + SplashScreen: { + launchShowDuration: 0, + }, + }, + android: { + buildOptions: { + releaseType: "APK", + keystorePath: process.env.KEYSTORE_PATH, + keystorePassword: process.env.KEYSTORE_PASSWORD, + keystoreAlias: process.env.KEYSTORE_ALIAS, + keystoreAliasPassword: process.env.KEYSTORE_ALIAS_PASSWORD, + signingType: "apksigner", + }, + }, + ios: { + scheme: "NiimBlues", + }, +}; + +export default config; + + + +require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' + +platform :ios, '15.0' +use_frameworks! + +# workaround to avoid Xcode caching of Pods that requires +# Product -> Clean Build Folder after new Cordova plugins installed +# Requires CocoaPods 1.6 or newer +install! 'cocoapods', :disable_input_output_paths => true + +def capacitor_pods + pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorCommunityBluetoothLe', :path => '../../node_modules/@capacitor-community/bluetooth-le' + pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' + pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' + pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' +end + +target 'NiimBlues' do + capacitor_pods + # Add your Pods here +end + +post_install do |installer| + assertDeploymentTarget(installer) +end + + + + + +
+ + + + + valueUpdated(e.currentTarget.value)} /> + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +{#if selectedText instanceof fabric.Textbox} + +{/if} + +{#if selectedText instanceof TextboxExt} + + +{/if} + + +
+ + fontSizeChange(e.currentTarget.valueAsNumber)} /> + + +
+ +
+ + + + lineHeightChange(e.currentTarget.valueAsNumber)} /> +
+ + + + + + +
+ + + + + +
+ {#if pagesTotal > 1} + + {/if} + + + + {#if pagesTotal > 1} + + {/if} +
+ +
+ {#if pagesTotal > 1}
Page {page + 1} / {pagesTotal}
{/if} + + {#if printState === "sending"} +
Sending...
+ {/if} + {#if printState === "printing"} +
+ Printing... +
+
{printProgress}%
+
+
+ {/if} + + {#if error} + + {/if} +
+ + {#snippet footer()} +
+ {$tr("preview.postprocess")} + + + + + + +
+ +
+ {$tr("preview.threshold")} + + updateSavedProp("threshold", thresholdValue, true)} /> + {thresholdValue} + + +
+ +
+ {$tr("preview.copies")} + updateSavedProp("quantity", quantity)} /> + +
+ +
+ {$tr("preview.density")} + updateSavedProp("density", density)} /> + +
+ + {#if printTaskName === "D110M_V4"} +
+ {$tr("preview.speed")} + + + +
+ {/if} + +
+ {$tr("preview.label_type")} + + + +
+ +
+ {$tr("preview.print_task")} + + + +
+ +
+ {$tr("preview.offset")} + {#if offsetWarning} + + {/if} + + updateSavedProp("offset", offset, true)} /> + + updateSavedProp("offset", offset, true)} /> + + + +
+ + + + {#if printState !== "idle"} + + {/if} + + + + + {/snippet} +
+ + +
+ + +import QRCodeFactory from "qrcode-generator"; +import * as fabric from "fabric"; +import { OBJECT_SIZE_DEFAULTS } from "$/defaults"; +import { Range } from "$/types"; +import { stringToBytes } from "$/utils/qrcode"; +import { CanvasUtils } from "$/utils/canvas_utils"; + +QRCodeFactory.stringToBytes = stringToBytes; + +export type ErrorCorrectionLevel = "L" | "M" | "Q" | "H"; +export type Mode = "Numeric" | "Alphanumeric" | "Byte" /* Default */ | "Kanji"; + +export type QrVersion = Range<41>; // 0-40, 0 is automatic + +export const qrCodeDefaultValues: Partial> = { + text: "Text", + ecl: "M", + stroke: "#000000", + fill: "#ffffff", + mode: "Byte", + qrVersion: 0, + ...OBJECT_SIZE_DEFAULTS, +}; + +interface UniqueQRCodeProps { + text: string; + ecl: ErrorCorrectionLevel; + mode: Mode; + qrVersion: QrVersion; +} +export interface QRCodeProps extends fabric.FabricObjectProps, UniqueQRCodeProps {} +export interface SerializedQRCodeProps extends fabric.SerializedObjectProps, UniqueQRCodeProps {} +const QRCODE_PROPS = ["text", "ecl", "size", "mode", "qrVersion"] as const; + +export class QRCode< + Props extends fabric.TOptions = Partial, + SProps extends SerializedQRCodeProps = SerializedQRCodeProps, + EventSpec extends fabric.ObjectEvents = fabric.ObjectEvents, + > + extends fabric.FabricObject + implements QRCodeProps +{ + static override readonly type = "QRCode"; + + /** + * QRCode text + */ + declare text: string; + + /** + * Error Correction Level + */ + declare ecl: ErrorCorrectionLevel; + + /** + * Mode + */ + declare mode: Mode; + + /** + * Version + */ + declare qrVersion: QrVersion; + + constructor(options?: Props) { + super(); + Object.assign(this, qrCodeDefaultValues); + this.setOptions(options); + this.lockScalingFlip = true; + this.setControlsVisibility({ + ml: false, + mt: false, + mr: false, + mb: false, + tl: false, + tr: false, + bl: false, + }); + } + + override _set(key: string, value: any): this { + super._set(key, value); + if (key === "text" || key === "ecl") { + this.dirty = true; + } + + return this; + } + + override _render(ctx: CanvasRenderingContext2D): void { + if (!this.text) { + CanvasUtils.renderError(ctx, this.width, this.height); + super._render(ctx); + return; + } + + const qr = QRCodeFactory(this.qrVersion, this.ecl); + + try { + qr.addData(this.text, this.mode); + qr.make(); + } catch (e) { + console.error(e); + CanvasUtils.renderError(ctx, this.width, this.height); + super._render(ctx); + return; + } + + const qrScale = Math.floor(this.width / qr.getModuleCount()); + let qrWidth = qrScale * qr.getModuleCount(); + qrWidth -= qrWidth % 2; // avoid half-pixel rendering + + if (qrScale < 1 || qrWidth > this.width) { + CanvasUtils.renderError(ctx, this.width, this.height); + super._render(ctx); + return; + } + + ctx.save(); + ctx.translate(-qrWidth / 2, -qrWidth / 2); // make top-left origin + ctx.translate(-0.5, -0.5); // blurry rendering fix + qr.renderTo2dContext(ctx, qrScale); + ctx.restore(); + + super._render(ctx); + } + + override toObject(propertiesToInclude: any[] = []) { + return super.toObject([...QRCODE_PROPS, ...propertiesToInclude]); + } +} + +fabric.classRegistry.setClass(QRCode, "QRCode"); + +export default QRCode; + + + +import lang_cs from "$/locale/dicts/cs.json"; +import lang_de from "$/locale/dicts/de.json"; +import lang_en from "$/locale/dicts/en.json"; +import lang_it from "$/locale/dicts/it.json"; +import lang_ru from "$/locale/dicts/ru.json"; +import lang_pl from "$/locale/dicts/pl.json"; +import lang_zh_cn from "$/locale/dicts/zh_cn.json"; +import lang_zh_tw from "$/locale/dicts/zh_tw.json"; +import lang_fr from "$/locale/dicts/fr.json"; +import lang_pt_br from "$/locale/dicts/pt_BR.json"; +import lang_hr from "$/locale/dicts/hr.json"; +import lang_ko_kr from "$/locale/dicts/ko_KR.json"; +import lang_es from "$/locale/dicts/es.json"; +import lang_ar from "$/locale/dicts/ar.json"; +import lang_hu from "$/locale/dicts/hu.json"; +import lang_tr from "$/locale/dicts/tr.json"; +import lang_hi from "$/locale/dicts/hi.json"; +import lang_mr from "$/locale/dicts/mr.json"; +import lang_bg from "$/locale/dicts/bg.json"; + +export type TranslationKey = keyof typeof lang_en; +export type TranslationDict = Record; + +export const langPack = { + /** English (fallback) */ + en: lang_en, + /** Czech */ + cs: lang_cs as TranslationDict, + /** German */ + de: lang_de as TranslationDict, + /** Italian */ + it: lang_it as TranslationDict, + /** Russian */ + ru: lang_ru as TranslationDict, + /** Polish */ + pl: lang_pl as TranslationDict, + /** Simplified Chinese */ + zh_cn: lang_zh_cn as TranslationDict, + /** Traditional Chinese */ + zh_tw: lang_zh_tw as TranslationDict, + /** French */ + fr: lang_fr as TranslationDict, + /** Portuguese (Brazil) */ + pt_br: lang_pt_br as TranslationDict, + /** Croatian */ + hr: lang_hr as TranslationDict, + /** Korean */ + ko_kr: lang_ko_kr as TranslationDict, + /** Spanish */ + es: lang_es as TranslationDict, + /** Arabic */ + ar: lang_ar as TranslationDict, + /** Hungarian */ + hu: lang_hu as TranslationDict, + /** Turkish */ + tr: lang_tr as TranslationDict, + /** Hindi */ + hi: lang_hi as TranslationDict, + /** Marathi */ + mr: lang_mr as TranslationDict, + /** Bulgarian */ + bg: lang_bg as TranslationDict, +} as const; + +export type SupportedLanguage = keyof typeof langPack; + +export const languageNames = Object.assign( + {}, + ...Object.entries(langPack).map(([k, v]) => ({ + [k]: v["lang.name"] ?? k, + })), +) as Record; + + + +import { get, readable, writable } from "svelte/store"; +import { + AppConfigSchema, + CsvParamsSchema, + UserFontSchema, + UserIconSchema, + type CsvParams, + type UserFont, + type UserIcon, + type AppConfig, + type AutomationProps, + type ConnectionState, + type ConnectionType, +} from "$/types"; +import { + NiimbotBluetoothClient, + NiimbotCapacitorBleClient, + NiimbotSerialClient, + RequestCommandId, + ResponseCommandId, + Utils, + instantiateClient, + type HeartbeatData, + type NiimbotAbstractClient, + type PrinterInfo, + type PrinterModelMeta, + type RfidInfo, +} from "@mmote/niimbluelib"; +import { Toasts } from "$/utils/toasts"; +import { tr } from "$/utils/i18n"; +import { LocalStoragePersistence, writablePersisted } from "$/utils/persistence"; +import { APP_CONFIG_DEFAULTS, CSV_DEFAULT, OBJECT_DEFAULTS_TEXT } from "$/defaults"; +import z from "zod"; +import { FileUtils } from "$/utils/file_utils"; + +export const fontCache = writable([OBJECT_DEFAULTS_TEXT.fontFamily]); +export const appConfig = writablePersisted("config", AppConfigSchema, APP_CONFIG_DEFAULTS); +export const userIcons = writablePersisted("user_icons", z.array(UserIconSchema), []); +export const userFonts = writablePersisted("user_fonts", z.array(UserFontSchema), []); +export const loadedFonts = writable([]); + +export const connectionState = writable("disconnected"); +export const connectedPrinterName = writable(""); +export const printerClient = writable(); +export const heartbeatData = writable(); +export const printerInfo = writable(); +export const rfidInfo = writable(); +export const ribbonRfidInfo = writable(); +export const printerMeta = writable(); +export const heartbeatFails = writable(0); +export const csvData = writablePersisted("csv_params", CsvParamsSchema, { data: CSV_DEFAULT }); + +userFonts.subscribe(FileUtils.loadFonts); + +export const automation = readable( + (() => { + try { + return LocalStoragePersistence.loadAutomation() ?? undefined; + } catch (e) { + console.error(e); + } + return undefined; + })(), +); + +export const refreshRfidInfo = () => { + const client = get(printerClient); + + if (!client) { + return; + } + + client.abstraction.rfidInfo().then(rfidInfo.set).catch(console.error); + + client.abstraction + .rfidInfo2() + .then(ribbonRfidInfo.set) + .catch(() => {}); +}; + +export const initClient = (connectionType: ConnectionType) => { + printerClient.update((prevClient: NiimbotAbstractClient) => { + let newClient: NiimbotAbstractClient = prevClient; + + if ( + prevClient === undefined || + (connectionType !== "bluetooth" && prevClient instanceof NiimbotBluetoothClient) || + (connectionType !== "serial" && prevClient instanceof NiimbotSerialClient) || + (connectionType !== "capacitor-ble" && prevClient instanceof NiimbotCapacitorBleClient) + ) { + if (prevClient !== undefined) { + prevClient.disconnect(); + } + + newClient = instantiateClient(connectionType); + + const conf = get(appConfig); + + if (conf.packetIntervalMs !== undefined) { + newClient.setPacketInterval(conf.packetIntervalMs); + } + + newClient.on("packetsent", (e) => { + console.log(`>> ${Utils.bufToHex(e.packet.toBytes())} (${RequestCommandId[e.packet.command]})`); + }); + + newClient.on("packetreceived", (e) => { + console.log(`<< ${Utils.bufToHex(e.packet.toBytes())} (${ResponseCommandId[e.packet.command]})`); + }); + + newClient.on("connect", (e) => { + console.log("onConnect"); + heartbeatFails.set(0); + connectionState.set("connected"); + connectedPrinterName.set(e.info.deviceName ?? "unknown"); + }); + + newClient.on("printerinfofetched", (e) => { + console.log("printerInfoFetched"); + printerInfo.set(e.info); + printerMeta.set(newClient.getModelMetadata()); + }); + + newClient.on("disconnect", () => { + console.log("onDisconnect"); + connectionState.set("disconnected"); + connectedPrinterName.set(""); + printerInfo.set({}); + printerMeta.set(undefined); + }); + + newClient.on("heartbeat", (e) => { + heartbeatFails.set(0); + heartbeatData.update((prev) => { + if ( + prev?.paperRfidSuccess !== e.data?.paperRfidSuccess || + prev?.ribbonRfidSuccess !== e.data?.ribbonRfidSuccess + ) { + refreshRfidInfo(); + } + return e.data; + }); + }); + + newClient.on("heartbeatfailed", (e) => { + const maxFails = 5; + heartbeatFails.set(e.failedAttempts); + + console.warn(`Heartbeat failed ${e.failedAttempts}/${maxFails}`); + if (e.failedAttempts >= maxFails) { + Toasts.error(get(tr)("connector.disconnect.heartbeat")); + newClient.disconnect(); + } + }); + } + + return newClient; + }); +}; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +[![logo](about/logo.svg)](https://niim.blue) + +# NIIMBOT printers webui + +Design and print labels right from your browser + +[FAQ](https://github.com/MultiMote/niimblue/wiki/Frequently-asked-questions) | [Discord](https://discord.gg/jXPAfZVd8a) | [Telegram](https://t.me/niimblue) | [NIIMBOT Community Wiki](https://printers.niim.blue) + +[NiimBlueLib](https://github.com/MultiMote/niimbluelib) is used for communication + +Deployments: + +[niim.blue](https://niim.blue) ![de](https://github.com/user-attachments/assets/07e72fdf-dd32-47a6-9071-43e77e9be7fa) main | [dev.niim.blue](https://dev.niim.blue) ![de](https://github.com/user-attachments/assets/07e72fdf-dd32-47a6-9071-43e77e9be7fa) dev | [2.niim.blue](https://2.niim.blue) ![ru](https://github.com/user-attachments/assets/b85647b0-4fb3-4b39-9da0-c31ec171067c) main + +Support project: + +[Boosty](https://boosty.to/multimote) | [Lava](https://app.lava.top/mithriss_art?tabId=donate) (less fees) + + +
+ +## Features + +* Privacy first! This application works completely offline (at browser side) and does not send any data (except for downloading application files and importing ZPL labels). Label data is stored in your browser. +* Support for both Bluetooth and USB connections. +* Rich label editor. Label saving, import/export. +* Print preview. You can see how your label will look like after post-processing. Several post-processing algorithms are available. +* [Standalone apps](https://github.com/MultiMote/niimblue/releases): + - Android (Capacitor based) + - Windows (Tauri based, uses Edge backed) +* Most complete implementation of [NIIMBOT protocol](https://printers.niim.blue/interfacing/proto/). + +You can see more complete list of implemented and planned features [on the Wiki](https://github.com/MultiMote/niimblue/wiki#features). + +Demonstration video: + +[![demo video](https://img.youtube.com/vi/u8QX-5e3W_A/mqdefault.jpg)](https://www.youtube.com/watch?v=u8QX-5e3W_A) + +## Supported printers + +There is no exact list of supported models in this project. This project aims to support the maximum number of models. + +You can check [a list of tested models here](https://github.com/MultiMote/niimbluelib/issues/1). If you own other model, please write a comment. + +If your (new) printer model does not print, please make a [packet dump](https://github.com/MultiMote/niimblue/wiki/Making-packet-capture) of print with official application. + +> [!NOTE] +> If you have printing problems, try different print task versions in print preview dialog. Make if default by pressing "Lock" button. + + +## Supported browsers + +Your browser must support Web Bluetooth API: [supported browsers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility). + +For serial communication: [supported browsers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility). + +Modern Chrome-based browsers should usually work. + +In some systems you need to enable Chrome `Web Bluetooth` or `Experimental Web Platform Features` (navigate to `chrome://flags`). + +## Images + +Images may be outdated. + +![ui](about/ui.png) + +
+⬇ More images ⬇ + +Label properties: + +![labels](about/labels.png) + +Save/load menu: + +![save_load](about/save_load.png) + +Print preview dialog: + +![print_preview](about/print_preview.png) + +Post-processing: + +![dither](about/dither.png) + +Templating: + +![templating](about/templating.png) + +Dynamic data: + +![batch](about/batch.png) + +In real life: + +![printed_b1](about/printed_b1.jpg) + +![printed_d110](about/printed_d110.jpg) +
+ + +## Development + +### Launching development server + +Skip steps you have done. + +1. Install [git](https://git-scm.com) + +2. Install [nodejs](https://nodejs.org) + +3. Clone repository + + ```bash + git clone https://github.com/MultiMote/niimblue.git + ``` + +4. Install dependencies + + ```bash + npm i + ``` + +5. Run dev server + + Check code and run: + + ```bash + npm run dev-check + ``` + + Or just run: + + ```bash + npm run dev + ``` + +### Deployment + +Here are some options. HTTPS is required for non-localhost deployments. + +#### Serving static files + +To get static files run `npm run build` (result builds to `dist`) or download `niimblue-dist.zip` from [Releases](https://github.com/MultiMote/niimblue/releases). + +#### Using Docker Image + +Follow the instructions in the [wiki](https://github.com/MultiMote/niimblue/wiki/Running-own-instance-with-Docker). + +### IDE setup + +Project uses path aliases. + +VSCode (settings.json): + +```json +{ + "typescript.preferences.importModuleSpecifier": "non-relative", + "javascript.preferences.importModuleSpecifier": "non-relative" +} +``` + +## Translations (click to contribute) + +[![translation](https://weblate.mmote.ru/widget/niimblue/web/multi-auto.svg)](https://weblate.mmote.ru/engage/niimblue/) +
+ + +import * as fabric from "fabric"; +import { DEFAULT_LABEL_PROPS, GRID_SIZE } from "$/defaults"; +import type { LabelProps } from "$/types"; + +type LabelBounds = { + startX: number; + startY: number; + endX: number; + endY: number; + width: number; + height: number; +}; +type FoldSegment = { start: number; end: number }; +type FoldInfo = { + axis: "vertical" | "horizontal" | "none"; + points: number[]; + segments: FoldSegment[]; +}; +type MirrorInfo = { pos: fabric.Point; flip: boolean }; + +export class CustomCanvas extends fabric.Canvas { + private labelProps: LabelProps = DEFAULT_LABEL_PROPS; + private readonly SEPARATOR_LINE_WIDTH = 2; + private readonly ROUND_RADIUS = 10; + private readonly TAIL_WIDTH = 40; + private readonly GRAY = "#CFCFCF"; + private readonly MIRROR_GHOST_COLOR = "rgba(0, 0, 0, 0.3)"; + private customBackground: boolean = true; + private highlightMirror: boolean = true; + private gridEnabled: boolean = false; + private virtualZoomRatio: number = 1; + onZoomChange?: (zoom: number) => void; + + constructor( + el?: string | HTMLCanvasElement, + options?: fabric.TOptions, + ) { + super(el, options); + this.setupZoom(); + this.preserveObjectStacking = true; + } + + private setupZoom() { + this.on("mouse:wheel", (opt) => { + const event = opt.e as WheelEvent; + event.preventDefault(); + + const delta = event.deltaY; + if (delta > 0) { + this.virtualZoomOut(); + } else { + this.virtualZoomIn(); + } + }); + } + + public virtualZoom(newZoom: number) { + this.virtualZoomRatio = Math.min(Math.max(0.25, newZoom), 4); + this.setDimensions( + { + width: this.virtualZoomRatio * this.getWidth() + "px", + height: this.virtualZoomRatio * this.getHeight() + "px", + }, + { cssOnly: true }, + ); + if (this.onZoomChange) { + this.onZoomChange(this.virtualZoomRatio); + } + } + + public virtualZoomIn() { + this.virtualZoom(this.virtualZoomRatio * 1.05); + } + + public virtualZoomOut() { + this.virtualZoom(this.virtualZoomRatio * 0.95); + } + + public getVirtualZoom(): number { + return this.virtualZoomRatio; + } + + public resetVirtualZoom() { + this.virtualZoom(1); + } + + setLabelProps(value: LabelProps) { + this.labelProps = value; + this.requestRenderAll(); + } + + setCustomBackground(value: boolean) { + this.customBackground = value; + } + + setHighlightMirror(value: boolean) { + this.highlightMirror = value; + } + + setGridEnabled(value: boolean) { + this.gridEnabled = value; + this.requestRenderAll(); + } + + /** Get label bounds without tail */ + getLabelBounds(): LabelBounds { + let endX = this.width ?? 1; + let endY = this.height ?? 1; + let startX = 0; + let startY = 0; + + if (this.labelProps.tailPos === "right") { + endX -= this.labelProps.tailLength ?? 0; + } else if (this.labelProps.tailPos === "bottom") { + endY -= this.labelProps.tailLength ?? 0; + } else if (this.labelProps.tailPos === "left") { + startX += this.labelProps.tailLength ?? 0; + } else if (this.labelProps.tailPos === "top") { + startY += this.labelProps.tailLength ?? 0; + } + + const width = endX - startX; + const height = endY - startY; + + return { startX, startY, endX, endY, width, height }; + } + + /** Get fold line position for splitted labels */ + getFoldInfo(): FoldInfo { + const bb = this.getLabelBounds(); + const points: number[] = []; + const segments: FoldSegment[] = []; + const splitParts = this.labelProps.splitParts ?? 2; + + if (splitParts < 2) { + return { axis: "none", points, segments }; + } + + if (this.labelProps.split === "horizontal") { + const segmentHeight = bb.height / splitParts; + let lastY: number = bb.startY; + + for (let i = 1; i < splitParts; i++) { + const y = + bb.startY + segmentHeight * i - this.SEPARATOR_LINE_WIDTH / 2 + 1; + points.push(y); + segments.push({ start: lastY, end: y }); + lastY = y; + } + + segments.push({ start: lastY, end: bb.endY }); + + return { axis: "horizontal", points, segments }; + } else if (this.labelProps.split === "vertical") { + const segmentWidth = bb.width / splitParts; + let lastX: number = bb.startX; + + for (let i = 1; i < splitParts; i++) { + const x = + bb.startX + segmentWidth * i - this.SEPARATOR_LINE_WIDTH / 2 + 1; + points.push(x); + segments.push({ start: lastX, end: x }); + lastX = x; + } + + segments.push({ start: lastX, end: bb.endX }); + + return { axis: "vertical", points, segments }; + } + + return { axis: "none", points, segments }; + } + + override _renderBackground(ctx: CanvasRenderingContext2D) { + if (this.width === undefined || this.height === undefined) { + return; + } + + ctx.save(); + ctx.fillStyle = "white"; + + // Draw simple white background and exit + if (!this.customBackground) { + ctx.fillRect(0, 0, this.width, this.height); + ctx.restore(); + return; + } + + // Disable further actions for circle labels, just render + if (this.labelProps.shape === "circle") { + ctx.beginPath(); + ctx.arc(this.width / 2, this.height / 2, this.height / 2, 0, 2 * Math.PI); + ctx.fill(); + ctx.restore(); + return; + } + + let roundRadius = this.ROUND_RADIUS; + const bb = this.getLabelBounds(); + const fold = this.getFoldInfo(); + + if (this.labelProps.shape !== "rounded_rect") { + roundRadius = 0; + } + + // Draw tail + ctx.fillStyle = this.GRAY; + + ctx.beginPath(); + if ( + this.labelProps.tailLength !== undefined && + this.labelProps.tailLength > 0 + ) { + if (this.labelProps.tailPos === "right") { + ctx.rect( + bb.endX - roundRadius, + bb.endY / 2 - this.TAIL_WIDTH / 2, + this.width - bb.endX + roundRadius, + this.TAIL_WIDTH, + ); + } else if (this.labelProps.tailPos === "bottom") { + ctx.rect( + bb.endX / 2 - this.TAIL_WIDTH / 2, + bb.endY - roundRadius, + this.TAIL_WIDTH, + this.height - bb.endY + roundRadius, + ); + } else if (this.labelProps.tailPos === "left") { + ctx.rect( + 0, + bb.endY / 2 - this.TAIL_WIDTH / 2, + bb.startX + roundRadius, + this.TAIL_WIDTH, + ); + } else if (this.labelProps.tailPos === "top") { + ctx.rect( + bb.endX / 2 - this.TAIL_WIDTH / 2, + 0, + this.TAIL_WIDTH, + bb.startY + roundRadius, + ); + } + } + ctx.fill(); + + // Draw label(s) + ctx.fillStyle = "white"; + + ctx.beginPath(); + + const splitParts = this.labelProps.splitParts ?? 2; + + if (this.labelProps.shape === "rounded_rect") { + if (this.labelProps.split === "horizontal") { + const segmentHeight = bb.height / splitParts; + ctx.roundRect( + bb.startX, + bb.startY, + bb.width, + segmentHeight, + roundRadius, + ); // First part + fold.points.forEach((y) => + ctx.roundRect(bb.startX, y, bb.width, segmentHeight, roundRadius), + ); // Other parts + } else if (this.labelProps.split === "vertical") { + const segmentWidth = bb.width / splitParts; + ctx.roundRect( + bb.startX, + bb.startY, + segmentWidth, + bb.height, + roundRadius, + ); // First part + fold.points.forEach((x) => + ctx.roundRect(x, bb.startY, segmentWidth, bb.height, roundRadius), + ); // Other parts + } else { + ctx.roundRect(0, 0, this.width, this.height, roundRadius); + } + } else { + ctx.rect(bb.startX, bb.startY, bb.width, bb.height); + } + + ctx.fill(); + + // Draw separator + + ctx.strokeStyle = this.GRAY; + ctx.lineWidth = this.SEPARATOR_LINE_WIDTH; + ctx.setLineDash([8, 8]); + ctx.beginPath(); + + if (fold.axis === "horizontal") { + fold.points.forEach((x) => { + ctx.moveTo(bb.startX + roundRadius, x); + ctx.lineTo(bb.endX - roundRadius, x); + }); + } else if (fold.axis === "vertical") { + fold.points.forEach((y) => { + ctx.moveTo(y, bb.startY + roundRadius); + ctx.lineTo(y, bb.endY - roundRadius); + }); + } + + ctx.stroke(); + + // Draw grid + if (this.gridEnabled) { + ctx.setLineDash([]); + ctx.strokeStyle = "rgba(100, 100, 255, 0.25)"; + ctx.lineWidth = 1; + ctx.beginPath(); + + const step = GRID_SIZE * 5; + for (let x = bb.startX + step; x < bb.endX; x += step) { + ctx.moveTo(x, bb.startY); + ctx.lineTo(x, bb.endY); + } + for (let y = bb.startY + step; y < bb.endY; y += step) { + ctx.moveTo(bb.startX, y); + ctx.lineTo(bb.endX, y); + } + ctx.stroke(); + } + + ctx.restore(); + } + override _renderObjects( + ctx: CanvasRenderingContext2D, + objects: fabric.FabricObject[], + ) { + super._renderObjects(ctx, objects); + + if (!this.highlightMirror || this.getActiveObjects().length > 1) { + return; + } + + ctx.save(); + + objects.forEach((obj) => { + const infos = this.getMirroredObjectCoords(obj); + infos.forEach((info) => { + const bbox = obj.getBoundingRect(); + ctx.fillStyle = this.MIRROR_GHOST_COLOR; + ctx.fillRect( + info.pos.x - bbox.width / 2, + info.pos.y - bbox.height / 2, + bbox.width, + bbox.height, + ); + ctx.restore(); + }); + }); + ctx.restore(); + } + + /** + * Return new object positions (origin is center) if object needs mirroring + **/ + getMirroredObjectCoords(obj: fabric.FabricObject): MirrorInfo[] { + const fold = this.getFoldInfo(); + const result: MirrorInfo[] = []; + + if ( + fold.axis === "none" || + !(this.labelProps.mirror === "flip" || this.labelProps.mirror === "copy") + ) { + return result; + } + + const bounds = this.getLabelBounds(); + + if (fold.axis === "vertical") { + if (this.labelProps.mirror === "copy") { + fold.points.forEach((x) => { + const pos = obj.getPointByOrigin("center", "center"); + pos.setX(x + (pos.x - bounds.startX)); + result.push({ pos, flip: false }); + }); + } else if ( + this.labelProps.mirror === "flip" && + fold.points.length === 1 + ) { + // Half split only supported + const axisX = fold.points[0]; + const pos = obj.getPointByOrigin("center", "center"); + pos.setX(axisX + (axisX - pos.x)); + pos.setY(bounds.startY + bounds.endY - pos.y); + result.push({ pos, flip: true }); + } + } else if (fold.axis === "horizontal") { + if (this.labelProps.mirror === "copy") { + fold.points.forEach((y) => { + const pos = obj.getPointByOrigin("center", "center"); + pos.setY(y + (pos.y - bounds.startY)); + result.push({ pos, flip: false }); + }); + } else if ( + this.labelProps.mirror === "flip" && + fold.points.length === 1 + ) { + // Half split only supported + const axisY = fold.points[0]; + const pos = obj.getPointByOrigin("center", "center"); + pos.setY(axisY + (axisY - pos.y)); + pos.setX(bounds.startX + bounds.endX - pos.x); + result.push({ pos, flip: true }); + } + } + + return result; + } + + /** Clone mirrored objects and add them to canvas */ + async createMirroredObjects() { + const objects = this.getObjects(); + for (const obj of objects) { + const infos = this.getMirroredObjectCoords(obj); + + for (const info of infos) { + const newObj = await obj.clone(); + newObj.setPositionByOrigin(info.pos, "center", "center"); + if (info.flip) { + newObj.centeredRotation = true; + newObj.rotate((newObj.angle + 180) % 360); + } + this.add(newObj); + } + } + } + + /** Centers object horizontally in the canvas or label part */ + override centerObjectH(object: fabric.FabricObject): void { + if ((this.labelProps.split ?? "none") !== "none") { + const pos = object.getPointByOrigin("center", "center"); + const bounds = this.getLabelBounds(); + const fold = this.getFoldInfo(); + let centerX = bounds.startX + bounds.width / 2; + + if (fold.axis !== "horizontal") { + fold.segments.forEach((seg) => { + if (pos.x >= seg.start && pos.x <= seg.end) { + centerX = seg.start + (seg.end - seg.start) / 2; + } + }); + } + pos.setX(centerX); + + object.setPositionByOrigin(pos, "center", "center"); + return; + } + + super.centerObjectH(object); + } + + /** Centers object vertically in the canvas or label part */ + override centerObjectV(object: fabric.FabricObject): void { + if ((this.labelProps.split ?? "none") !== "none") { + const pos = object.getPointByOrigin("center", "center"); + const bounds = this.getLabelBounds(); + const fold = this.getFoldInfo(); + let centerY = bounds.startY + bounds.height / 2; + + if (fold.axis !== "vertical") { + fold.segments.forEach((seg) => { + if (pos.y >= seg.start && pos.y <= seg.end) { + centerY = seg.start + (seg.end - seg.start) / 2; + } + }); + } + + pos.setY(centerY); + object.setPositionByOrigin(pos, "center", "center"); + return; + } + + super.centerObjectV(object); + } +} + + + +import * as fabric from "fabric"; +import QRCode from "$/fabric-object/qrcode"; +import Barcode from "$/fabric-object/barcode"; +import dayjs from "dayjs"; +import { TextboxExt } from "$/fabric-object/textbox-ext"; + +const VARIABLE_TEMPLATE_RX = /{\s*(\$?\w+)\s*(?:\|\s*(.*?)\s*)?}/g; + +const preprocessDateTime = (format?: string) => { + const dt = dayjs(); + if (format) { + return dt.format(format); + } + return dt.format("YYYY-MM-DD HH:mm:ss"); +}; + +const preprocessString = (input: string, variables?: { [v: string]: string }): string => { + return input.replace(VARIABLE_TEMPLATE_RX, (src, key, filter) => { + if (variables !== undefined && key in variables) { + return variables[key]; + } else if (key === "dt") { + return preprocessDateTime(filter); + } + return src; + }); +}; + +/** Replace text templates in some canvas objects */ +export const canvasPreprocess = (canvas: fabric.Canvas, variables?: { [key: string]: string }) => { + canvas.forEachObject((obj: fabric.FabricObject) => { + if (obj instanceof fabric.IText) { + const text = preprocessString(obj.text ?? "", variables); + + if (obj instanceof TextboxExt && obj.fontAutoSize) { + obj.setAndShrinkText(text, obj.width); + } else { + obj.set({ text }); + } + } else if (obj instanceof QRCode || obj instanceof Barcode) { + obj.set({ text: preprocessString(obj.text ?? "", variables) }); + } + }); +}; + + + +{ + "name": "niimblues", + "version": "1.0.0", + "description": "NiimBlue standalone app", + "type": "module", + "scripts": { + "build-www": "cd ../.. && vite build --outDir ./standalone-apps/capacitor/www", + "build-android": "cap sync android && cap build android", + "run-android": "cap sync android && cap run android", + "build-ios": "cap sync ios && cap open ios", + "run-ios": "cap sync ios && cap run ios" + }, + "dependencies": { + "@capacitor-community/bluetooth-le": "^8.1.3", + "@capacitor/android": "^8.3.0", + "@capacitor/core": "^8.3.0", + "@capacitor/filesystem": "^8.1.2", + "@capacitor/ios": "^8.3.0", + "@capacitor/share": "^8.0.1", + "@capacitor/splash-screen": "^8.0.1" + }, + "devDependencies": { + "@capacitor/cli": "^8.3.0" + }, + "overrides": { + "@capacitor/cli": { + "tar": "^7.5.11" + } + }, + "author": "MultiMote", + "license": "MIT" +} + + + +import * as fabric from "fabric"; +import ArUcoMarker from "$/fabric-object/aruco"; +import Barcode from "$/fabric-object/barcode"; +import QRCode from "$/fabric-object/qrcode"; +import { OBJECT_DEFAULTS_TEXT } from "$/defaults"; + +export class CanvasUtils { + static equalSpacingFillText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, printWidth: number) { + // calculate every character width, and spacing + const widths = []; + for (let i = 0; i < text.length; i++) { + const char = text.charAt(i); + const metrics = ctx.measureText(char); + widths.push(metrics.width); + } + const totalWidth = widths.reduce((a, b) => a + b, 0); + const spacing = (printWidth - totalWidth) / (text.length - 1); + + // print every character with calculated spacing + let offset = 0; + for (let i = 0; i < text.length; i++) { + const char = text.charAt(i); + ctx.fillText(char, x + offset, y); + offset += widths[i] + spacing; + } + } + + static fixFabricObjectScale(obj: fabric.FabricObject) { + const isNotScalable = obj instanceof Barcode || obj instanceof fabric.Rect || obj instanceof QRCode || obj instanceof ArUcoMarker; + + if (isNotScalable) { + obj.set({ + width: Math.round(obj.width * (obj.scaleX ?? 1)), + height: Math.round(obj.height * (obj.scaleY ?? 1)), + scaleX: 1, + scaleY: 1, + left: Math.round(obj.left), + top: Math.round(obj.top), + }); + + if (obj instanceof QRCode || obj instanceof ArUcoMarker) { + const qrMin = 42; + const size = Math.max(obj.width + (obj.width % 2), qrMin); + obj.set({ + width: size, + height: size, + }); + } + } + } + + static fitObjectIntoCanvas(canvas: fabric.Canvas, obj: fabric.FabricObject, xMargin: number, yMarin: number) { + const widthRatio = canvas.width / (obj.width + xMargin * 2); + const heightRatio = canvas.height / (obj.height + yMarin * 2); + const scaleFactor = Math.min(widthRatio, heightRatio); + obj.set({ left: xMargin, top: yMarin }); + obj.scale(scaleFactor); + canvas.centerObjectV(obj); + canvas.centerObjectH(obj); + } + + static renderError(ctx: CanvasRenderingContext2D, width: number, height: number): void { + ctx.save(); + ctx.fillStyle = "black"; + ctx.translate(-width / 2, -height / 2); // make top-left origin + ctx.translate(-0.5, -0.5); // blurry rendering fix + ctx.fillRect(0, 0, width + 1, height + 1); + ctx.restore(); + + ctx.save(); + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.font = `16px ${OBJECT_DEFAULTS_TEXT.fontFamily}`; + ctx.fillText("ERR", 0, 0); + ctx.restore(); + } +} + + + +import { LabelType, printTaskNames } from "@mmote/niimbluelib"; +import * as fabric from "fabric"; +import { z } from "zod"; + +export type ConnectionState = "connecting" | "connected" | "disconnected"; +export type ConnectionType = "bluetooth" | "serial" | "capacitor-ble"; + +export type LabelUnit = "mm" | "px"; +export type OjectType = "text" | "rectangle" | "line" | "circle" | "image" | "qrcode" | "barcode" | "aruco" | "pdf"; +export type PostProcessType = "threshold" | "dither" | "bayer"; +export type MoveDirection = "up" | "down" | "left" | "right"; +export type LabelShape = "rect" | "rounded_rect" | "circle"; +export type LabelSplit = "none" | "vertical" | "horizontal"; +export type TailPosition = "right" | "bottom" | "left" | "top"; +export type MirrorType = "none" | "copy" | "flip"; + +type _Range = R["length"] extends T ? R[number] : _Range; + +export type Range = number extends T ? number : _Range; + +export const CsvParamsSchema = z.object({ + data: z.string(), +}); + +/** Not validated */ +export const FabricObjectSchema = z.custom((val: any): boolean => { + return typeof val === "object"; +}); + +export const LabelPropsSchema = z.object({ + printDirection: z.enum(["left", "top"]), + size: z.object({ + width: z.number().positive(), + height: z.number().positive(), + }), + shape: z.enum(["rect", "rounded_rect", "circle"]).default("rect").optional(), + split: z.enum(["none", "vertical", "horizontal"]).default("none").optional(), + splitParts: z.number().min(1).default(2).optional(), + tailPos: z.enum(["right", "bottom", "left", "top"]).default("right").optional(), + tailLength: z.number().default(0).optional(), + mirror: z.enum(["none", "copy", "flip"]).default("none").optional(), +}); + +export const LabelPresetSchema = z.object({ + width: z.number().positive(), + height: z.number().positive(), + unit: z.enum(["mm", "px"]), + dpmm: z.number().positive(), + printDirection: z.enum(["left", "top"]), + title: z.string().optional(), + shape: z.enum(["rect", "rounded_rect", "circle"]).default("rect").optional(), + split: z.enum(["none", "vertical", "horizontal"]).default("none").optional(), + splitParts: z.number().min(1).default(2).optional(), + tailPos: z.enum(["right", "bottom", "left", "top"]).default("right").optional(), + tailLength: z.number().default(0).optional(), + mirror: z.enum(["none", "copy", "flip"]).default("none").optional(), +}); + +export const FabricJsonSchema = z.object({ + version: z.string(), + objects: z.array(FabricObjectSchema), +}); + +export const ExportedLabelTemplateSchema = z.object({ + canvas: FabricJsonSchema, + label: LabelPropsSchema, + thumbnailBase64: z.string().optional(), + title: z.string().optional(), + timestamp: z.number().positive().optional(), + id: z.string().optional(), // filled with localStorage key, not exported + csv: CsvParamsSchema.optional(), +}); + +const [firstTask, ...otherTasks] = printTaskNames; + +export const PreviewPropsOffsetSchema = z.object({ + x: z.number(), + y: z.number(), + offsetType: z.enum(["inner", "outer"]), +}); + +export const PreviewPropsSchema = z.object({ + postProcess: z.enum(["threshold", "dither", "bayer"]).optional(), + postProcessInvert: z.boolean().optional(), + threshold: z.number().gte(1).lte(255).optional(), + quantity: z.number().gte(1).optional(), + density: z.number().gte(1).optional(), + speed: z.union([z.literal(0), z.literal(1)]).optional(), + labelType: z.enum(LabelType).optional(), + printTaskName: z.enum([firstTask, ...otherTasks]).optional(), + offset: PreviewPropsOffsetSchema.optional(), +}); + +export const AutomationPropsSchema = z.object({ + /** Request device connect on page load. Works only for Capacitor BLE connection. */ + autoConnect: z.boolean().optional(), + /** Connect to MAC or device id. Works only for Capacitor BLE connection. */ + autoConnectDeviceId: z.string().optional(), + /** immediately - just open print preview dialog */ + startPrint: z.enum(["after_connect", "immediately"]).optional(), +}); + +export const AppConfigSchema = z.object({ + /** Keep image aspect ration when using "fit" button */ + fitMode: z.enum(["stretch", "ratio_min", "ratio_max"]), + pageDelay: z.number().gte(0).optional(), + iconListMode: z.enum(["user", "pack", "both"]), + packetIntervalMs: z.number().gte(0).optional(), + gridEnabled: z.boolean().optional(), +}); + +export const UserIconSchema = z.object({ + name: z.string(), + data: z.string(), +}); + +export const UserFontSchema = z + .object({ + gzippedDataB64: z.string(), + family: z.string(), + mimeType: z.string(), + }); + +export type CsvParams = z.infer; +export type UserIcon = z.infer; +export type LabelProps = z.infer; +export type LabelPreset = z.infer; +export type FabricJson = z.infer; +export type ExportedLabelTemplate = z.infer; +export type PreviewPropsOffset = z.infer; +export type PreviewProps = z.infer; +export type AutomationProps = z.infer; +export type AppConfig = z.infer; +export type UserFont = z.infer; + + + +import * as fabric from "fabric"; +import { + ExportedLabelTemplateSchema, + LabelPresetSchema, + UserFont, + type ExportedLabelTemplate, + type FabricJson, + type LabelPreset, + type LabelProps, +} from "$/types"; +import { OBJECT_DEFAULTS, OBJECT_DEFAULTS_VECTOR, THUMBNAIL_HEIGHT, THUMBNAIL_QUALITY } from "$/defaults"; +import { z } from "zod"; +import { CustomCanvas } from "$/fabric-object/custom_canvas"; +import { Capacitor } from "@capacitor/core"; +import { CanvasUtils } from "$/utils/canvas_utils"; +import { LocalStoragePersistence } from "./persistence"; +import { csvData, loadedFonts } from "$/stores"; +import { get } from "svelte/store"; + +export class FileUtils { + static timestamp(): number { + return Math.floor(Date.now() / 1000); + } + + static timestampFloat(): number { + return Date.now() / 1000; + } + + /** Convert string to base64 string */ + static base64str(str: string): string { + const bytes = new TextEncoder().encode(str); + const binString = String.fromCodePoint(...bytes); + return btoa(binString); + } + + /** Convert object to base64 string */ + static base64obj(obj: unknown): string { + const json: string = JSON.stringify(obj); + return FileUtils.base64str(json); + } + + /** Convert object to base64 string */ + static base64buf(buf: ArrayBuffer): Promise { + const blob = new Blob([buf]); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(",")[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + static async decompressData(buf: BufferSource): Promise { + const ds = new DecompressionStream("gzip"); + const writer = ds.writable.getWriter(); + writer.write(buf); + writer.close(); + return await new Response(ds.readable).arrayBuffer(); + } + + static async compressData(buf: BufferSource): Promise { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + writer.write(buf); + writer.close(); + return await new Response(cs.readable).arrayBuffer(); + } + + /** Convert base64 string to bytes */ + static base64toBytes(b64str: string): Uint8Array { + const binaryString = atob(b64str); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.codePointAt(i)!; + } + return bytes; + } + + static async blobToDataUrl(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (readerEvt: ProgressEvent) => { + if (readerEvt?.target?.result) { + resolve(readerEvt.target.result as string); + } + }; + reader.onerror = (readerEvt: ProgressEvent) => { + console.error(readerEvt); + reject(new Error("File read error")); + }; + }); + } + + static async downloadBase64Web(filename: string, mime: string, base64Data: string) { + const byteChars = atob(base64Data); + const byteNumbers = new Array(byteChars.length); + + for (let i = 0; i < byteChars.length; i++) { + byteNumbers[i] = byteChars.charCodeAt(i); + } + + const arr = new Uint8Array(byteNumbers); + const blob = new Blob([arr], { type: mime }); + + const a = document.createElement("a"); + a.download = filename; + a.href = URL.createObjectURL(blob); + a.click(); + + setTimeout(() => { + URL.revokeObjectURL(a.href); + a.remove(); + }, 10_000); + } + + static async downloadBase64Capacitor(filename: string, base64Data: string) { + const { Directory, Filesystem } = await import("@capacitor/filesystem"); + const { Share } = await import("@capacitor/share"); + + const result = await Filesystem.writeFile({ + data: base64Data, + path: filename, + directory: Directory.Cache, + }); + + await Share.share({ + title: filename, + text: filename, + url: result.uri, + }); + } + + static async downloadBase64(filename: string, mime: string, base64Data: string) { + if (Capacitor.getPlatform() !== "web") { + FileUtils.downloadBase64Capacitor(filename, base64Data); + return; + } + + FileUtils.downloadBase64Web(filename, mime, base64Data); + } + + static makeExportedLabel(canvas: fabric.Canvas, labelProps: LabelProps, includeCsv: boolean): ExportedLabelTemplate { + const thumbnailBase64: string = canvas.toDataURL({ + width: canvas.width, + height: canvas.height, + left: 0, + top: 0, + multiplier: THUMBNAIL_HEIGHT / (canvas.height || 1), + quality: THUMBNAIL_QUALITY, + format: "jpeg", + }); + + const tpl: ExportedLabelTemplate = { + canvas: canvas.toJSON(), + label: labelProps, + thumbnailBase64, + timestamp: FileUtils.timestamp(), + }; + + if (includeCsv) { + tpl.csv = get(csvData); + } + + tpl.id = LocalStoragePersistence.createUidForLabel(tpl); + + return tpl; + } + + /** Convert label template to JSON and download it */ + static saveLabelAsJson(label: ExportedLabelTemplate) { + const parsed = ExportedLabelTemplateSchema.omit({ id: true }).parse(label); + const timestamp = label.timestamp ?? FileUtils.timestamp(); + let filename = `label_${timestamp}.json`; + + if (parsed.title && parsed.title.trim().length > 0) { + filename = `${parsed.title}.json`; + } + + FileUtils.downloadBase64(filename, "application/json", FileUtils.base64obj(parsed)); + } + + /** Convert canvas to PNG and download it */ + static saveCanvasAsPng(canvas: fabric.Canvas) { + const timestamp = FileUtils.timestamp(); + + const url = canvas.toDataURL({ + width: canvas.width, + height: canvas.height, + left: 0, + top: 0, + format: "png", + multiplier: 1, + }); + + FileUtils.downloadBase64(`label_${timestamp}.png`, "image/png", url.split("base64,")[1]); + } + + /** Convert label template to JSON and download it */ + static saveLabelPresetsAsJson(presets: LabelPreset[]) { + const parsed = z.array(LabelPresetSchema).parse(presets); + FileUtils.downloadBase64(`presets_${FileUtils.timestamp()}.json`, "application/json", FileUtils.base64obj(parsed)); + } + + /** + * Open file picker and return file contents + * + * fixme: never ends if dialog closed + * + **/ + static async pickFileAsync(acceptExtension: string, multiple: boolean): Promise { + return new Promise((resolve) => { + const input: HTMLInputElement = document.createElement("input"); + + input.type = "file"; + input.multiple = multiple; + + if (acceptExtension !== "*") { + input.accept = `.${acceptExtension}`; + } + + input.onchange = (e: Event) => { + const target = e.target as HTMLInputElement; + if (target.files !== null && target.files.length > 0) { + resolve(target.files); + } + }; + input.click(); + }); + } + + static async pickAndReadTextFile(acceptExtension: string, multiple: boolean): Promise { + const fileList = await FileUtils.pickFileAsync(acceptExtension, multiple); + + const result: string[] = []; + + for (const file of fileList) { + const ext = file.name.split(".").pop(); + if (ext === acceptExtension) { + const data = await file.text(); + result.push(data); + } else { + throw new Error(`Only ${acceptExtension} allowed`); + } + } + + return result; + } + + static async pickAndReadSingleTextFile(acceptExtension: string): Promise { + const result = await FileUtils.pickAndReadTextFile(acceptExtension, false); + if (result.length === 0) { + throw new Error("No files processed"); + } + return result[0]; + } + + /** + * Open file picker and return file contents + * */ + static async pickAndReadBinaryFile(acceptExtension: string): Promise<{ name: string; data: ArrayBuffer }> { + const fileList = await FileUtils.pickFileAsync(acceptExtension, false); + const file: File = fileList[0]; + const ext = file.name.split(".").pop(); + + if (acceptExtension !== "*" && ext !== acceptExtension) { + throw new Error(`Only ${acceptExtension} allowed`); + } + + const data: ArrayBuffer = await file.arrayBuffer(); + return { name: file.name, data }; + } + + static async loadCanvasState(canvas: fabric.Canvas, state: FabricJson): Promise { + const deprecatedLines: fabric.Line[] = []; + + await canvas.loadFromJSON(state, (_, obj) => { + if (obj instanceof fabric.FabricObject) { + obj.set({ snapAngle: OBJECT_DEFAULTS.snapAngle }); + CanvasUtils.fixFabricObjectScale(obj); + + if (obj instanceof fabric.Line) { + deprecatedLines.push(obj); + } + } + }); + + // convert deprecated Line to Polyline + for (const line of deprecatedLines) { + const poly = new fabric.Polyline( + [ + { x: line.x1, y: line.y1 }, + { x: line.x2, y: line.y2 }, + ], + { + ...OBJECT_DEFAULTS_VECTOR, + left: line.left, + top: line.top, + angle: line.angle, + scaleX: line.scaleX, + scaleY: line.scaleY, + fill: line.fill, + stroke: line.stroke, + strokeWidth: line.strokeWidth, + }, + ); + + canvas.remove(line); + canvas.add(poly); + } + + if (canvas instanceof CustomCanvas) { + canvas.virtualZoom(canvas.getVirtualZoom()); + } + + canvas.requestRenderAll(); + } + + static printImageUrls(sources: string[]) { + const imgs = sources.map((src) => ``); + + const html = ` + + + + + + ${imgs.join("\n")} + + + `; + + const iframe = document.createElement("iframe"); + + iframe.onload = () => { + const iframeWindow = iframe.contentWindow!; + iframeWindow.onafterprint = () => iframe.remove(); + iframeWindow.print(); + }; + + iframe.style.display = "none"; + iframe.src = "about:blank"; + iframe.srcdoc = html; + + document.body.appendChild(iframe); + } + + static async makeLabelUrl(label: ExportedLabelTemplate): Promise { + const labelStr = JSON.stringify({ ...label, thumbnailBase64: undefined }); + + const encoder = new TextEncoder(); + const data = encoder.encode(labelStr); + + if (data.length > 2 * 1024 * 1024) { + throw new Error("Label data size > 2MB"); + } + + const compressed = await FileUtils.compressData(data); + const b64data = await FileUtils.base64buf(compressed); + return `${location.protocol}//${location.host}/#load=${b64data}`; + } + + static urlHashParamsToDict(): Record { + const anchorData = globalThis.location.hash.slice(1); + + if (!anchorData) { + return {}; + } + + return anchorData.split("&").reduce((res: Record, item: string) => { + const firstEqualsIndex = item.indexOf("="); + + if (firstEqualsIndex === -1) { + // Handle case without value (e.g., "key" without "=value") + res[item] = ""; + } else { + const key = item.slice(0, firstEqualsIndex); + const value = item.slice(firstEqualsIndex + 1); + res[key] = value; + } + + return res; + }, {}); + } + + static async readLabelFromUrl(): Promise { + const params = FileUtils.urlHashParamsToDict(); + + if ("uload" in params) { + const b64data: string = params["uload"]; + const jsonBytes = FileUtils.base64toBytes(b64data); + const jsonStr = new TextDecoder().decode(jsonBytes); + const labelObj = JSON.parse(jsonStr); + return ExportedLabelTemplateSchema.parse(labelObj); + } + + if (!("load" in params)) { + return null; + } + + const b64data: string = params["load"]; + const bytes = FileUtils.base64toBytes(b64data); + const decompressed = await FileUtils.decompressData(bytes); + const decoder = new TextDecoder(); + + const decoded = decoder.decode(decompressed); + const labelObj = JSON.parse(decoded); + return ExportedLabelTemplateSchema.parse(labelObj); + } + + static async loadFonts(fontsToLoad: UserFont[]) { + const loadedList = get(loadedFonts); + + for (const font of fontsToLoad) { + if (loadedList.some((e) => e.family === font.family)) { + continue; + } + + const bytes = FileUtils.base64toBytes(font.gzippedDataB64); + const decompressed = await FileUtils.decompressData(bytes); + const b64 = await FileUtils.base64buf(decompressed); + + const fontFace = new FontFace(font.family, `url(data:${font.mimeType};base64,${b64})`); + + try { + const loaded = await fontFace.load(); + loadedList.push(loaded); + document.fonts.add(loaded); + } catch (e) { + console.error(`Failed to load font ${font.family}:`, e); + } + } + + // remove font that not exist anymore + for (let i = loadedList.length - 1; i >= 0; i--) { + const loadedFont = loadedList[i]; + + if (!fontsToLoad.some((e) => e.family === loadedFont.family)) { + document.fonts.delete(loadedFont); + loadedList.splice(i, 1); + } + } + + loadedFonts.set(loadedList); + } +} + + + +{ + "name": "niimblue", + "private": true, + "type": "module", + "version": "0.0.1", + "scripts": { + "dev-check": "npm run sv-check && npm run dev", + "dev": "vite", + "dev-local-lib": "vite --force", + "build": "vite build", + "build-rel": "vite build --base=./", + "preview": "vite preview", + "sv-check": "svelte-check --tsconfig ./tsconfig.json", + "gen-mdi-list": "node gen-mdi-list.mjs > src/styles/mdi_icons.ts", + "lint": "eslint .", + "format-all": "prettier --write \"src/**/*.{ts,svelte}\" \"!src/styles/mdi_icons.ts\"" + }, + "dependencies": { + "@capacitor/core": "^8.3.0", + "@capacitor/filesystem": "^8.1.2", + "@capacitor/share": "^8.0.1", + "@fontsource-variable/noto-sans": "^5.2.10", + "@formatjs/intl-localematcher": "^0.6.2", + "@mmote/niimbluelib": "0.0.1-alpha.39", + "@popperjs/core": "^2.11.8", + "bootstrap": "5.3.8", + "d3-dsv": "^3.0.1", + "dayjs": "^1.11.19", + "fabric": "^7.1.0", + "material-icons": "^1.13.14", + "pdfjs-dist": "^5.4.624", + "qrcode-generator": "2.0.4", + "toastify-js": "^1.12.0", + "zod": "^4.1.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.0", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tsconfig/svelte": "^5.0.5", + "@types/bootstrap": "^5.2.10", + "@types/d3-dsv": "^3.0.7", + "@types/node": "^24.10.0", + "@types/qrcode-svg": "^1.1.5", + "@types/toastify-js": "^1.12.4", + "eslint-plugin-svelte": "^3.13.0", + "globals": "^16.5.0", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "sass": "1.77.6", + "svelte": "^5.43.2", + "svelte-check": "^4.3.3", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.3", + "vite": "^7.3.2" + }, + "overrides": { + "fabric": { + "canvas": "npm:uninstall" + } + } +} + + + +import * as fabric from "fabric"; +import type { AppConfig, LabelPreset, LabelProps } from "$/types"; +import { TextboxExt } from "$/fabric-object/textbox-ext"; + +export const configureFabric = () => { + fabric.config.disableStyleCopyPaste = true; + + fabric.classRegistry.setClass(TextboxExt, "Textbox"); + + fabric.Line.prototype.setControlsVisibility({ + tl: false, + bl: false, + tr: false, + br: false, + mt: false, + mb: false, + }); + + fabric.Polyline.prototype.setControlsVisibility({ + tl: false, + bl: false, + tr: false, + br: false, + mt: false, + mb: false, + }); +}; + +/** Default presets for LabelPropsEditor */ +export const DEFAULT_LABEL_PRESETS: LabelPreset[] = [ + // 203dpi + { width: 40, height: 12, unit: "mm", dpmm: 8, printDirection: "left", shape: "rect" }, + { width: 50, height: 30, unit: "mm", dpmm: 8, printDirection: "top", shape: "rect" }, + // 300dpi + { width: 40, height: 12, unit: "mm", dpmm: 11.81, printDirection: "left", shape: "rect", title: "40x12mm 300dpi" }, + { width: 50, height: 30, unit: "mm", dpmm: 11.81, printDirection: "top", shape: "rect", title: "50x30mm 300dpi" }, +]; + +/** Default canvas dimensions */ +export const DEFAULT_LABEL_PROPS: LabelProps = { + printDirection: "left", + size: { + width: 240, + height: 96, + }, +}; + +/** Object movement snapping */ +export const GRID_SIZE: number = 5; + +/** Newly created Fabric object dimensions */ +export const OBJECT_SIZE_DEFAULTS = { + width: 64, + height: 64, +}; + +/** Newly created Fabric object common properties */ +export const OBJECT_DEFAULTS = { + snapAngle: 10, + top: 10, + left: 10, + originX: "left" as fabric.TOriginX, + originY: "top" as fabric.TOriginY, +} as fabric.FabricObjectProps; + +/** Newly created Fabric vector object properties */ +export const OBJECT_DEFAULTS_VECTOR = { + ...OBJECT_DEFAULTS, + fill: "transparent", + stroke: "black", + strokeWidth: 3, + strokeUniform: true, +} as fabric.FabricObjectProps; + +/** Newly created Fabric text object properties */ +export const OBJECT_DEFAULTS_TEXT = { + ...OBJECT_DEFAULTS, + fill: "black", + fontFamily: "Noto Sans Variable", + textAlign: "center" as fabric.TextboxProps["textAlign"], + originX: "center" as fabric.TOriginX, + originY: "center" as fabric.TOriginY, + lineHeight: 1, +} as fabric.TextboxProps; + +/** Scale image to this height when making a label thumbnail */ +export const THUMBNAIL_HEIGHT = 48; + +/** Generate thumbnail in jpeg format with this quality */ +export const THUMBNAIL_QUALITY = 0.7; + +export const APP_CONFIG_DEFAULTS: AppConfig = { + fitMode: "stretch", + iconListMode: "both", + gridEnabled: false +}; + +export const CSV_DEFAULT = "var1,var2\n123,456\n777,888"; + + + +import * as fabric from "fabric"; +import { OBJECT_DEFAULTS, OBJECT_DEFAULTS_TEXT, OBJECT_DEFAULTS_VECTOR, OBJECT_SIZE_DEFAULTS } from "$/defaults"; +import { ArUcoMarker } from "$/fabric-object/aruco"; +import Barcode from "$/fabric-object/barcode"; +import { QRCode } from "$/fabric-object/qrcode"; +import type { OjectType } from "$/types"; +import { Toasts } from "$/utils/toasts"; +import { FileUtils } from "$/utils/file_utils"; +import { CanvasUtils } from "$/utils/canvas_utils"; +import { TextboxExt, TextboxExtProps } from "$/fabric-object/textbox-ext"; + +export class LabelDesignerObjectHelper { + static async addSvg(canvas: fabric.Canvas, svgCode: string): Promise { + const { objects, options } = await fabric.loadSVGFromString(svgCode); + const obj = fabric.util.groupSVGElements( + objects.filter((o) => o !== null), + options, + ); + obj.set({ ...OBJECT_DEFAULTS }); + CanvasUtils.fitObjectIntoCanvas(canvas, obj, OBJECT_DEFAULTS.left, OBJECT_DEFAULTS.top); + canvas.add(obj); + canvas.renderAll(); + return obj; + } + + static async addImageFile(canvas: fabric.Canvas, file: File): Promise { + if (file.type.startsWith("image/svg")) { + const data = await file.text(); + return await this.addSvg(canvas, data); + } + + if (file.type === "image/png" || file.type === "image/jpeg" || file.type === "image/bmp" || file.type === "image/gif") { + const url = await FileUtils.blobToDataUrl(file); + const fabricImg = await fabric.FabricImage.fromURL(url); + fabricImg.set({ ...OBJECT_DEFAULTS }); + CanvasUtils.fitObjectIntoCanvas(canvas, fabricImg, OBJECT_DEFAULTS.left, OBJECT_DEFAULTS.top); + canvas.add(fabricImg); + return fabricImg; + } + + throw new Error("Unsupported image"); + } + + static async addImageWithFilePicker(fabricCanvas: fabric.Canvas): Promise { + const files = await FileUtils.pickFileAsync("*", false); + try { + return await this.addImageFile(fabricCanvas, files[0]); + } catch (e) { + // fixme: catch error in other place + Toasts.error(e); + throw e; + } + } + + static async addImageBlob(fabricCanvas: fabric.Canvas, img: Blob): Promise { + const url = await FileUtils.blobToDataUrl(img); + const fabricImg = await fabric.FabricImage.fromURL(url); + fabricImg.set({ left: 0, top: 0, snapAngle: OBJECT_DEFAULTS.snapAngle }); + fabricCanvas.add(fabricImg); + return fabricImg; + } + + static async addObjectFromClipboard( + fabricCanvas: fabric.Canvas, + data: DataTransfer, + ): Promise { + // paste image + for (const item of data.items) { + if (item.type.includes("image")) { + const file = item.getAsFile(); + if (file) { + return await LabelDesignerObjectHelper.addImageFile(fabricCanvas, file); + } + } + } + + // paste text + const text = data.getData("text"); + if (text) { + const obj = LabelDesignerObjectHelper.addText(fabricCanvas, text); + fabricCanvas.setActiveObject(obj); + return obj; + } + } + + static addText(canvas: fabric.Canvas, text?: string, options?: Partial): TextboxExt { + const obj = new TextboxExt(text ?? "Text", { + ...OBJECT_DEFAULTS_TEXT, + ...options, + }); + canvas.add(obj); + canvas.centerObject(obj); + return obj; + } + + static addStaticText(canvas: fabric.Canvas, text?: string, options?: Partial): fabric.FabricText { + const obj = new fabric.FabricText(text ?? "Text", { + ...OBJECT_DEFAULTS_TEXT, + ...options, + }); + canvas.add(obj); + canvas.centerObject(obj); + return obj; + } + + static addHLine(canvas: fabric.Canvas): fabric.Polyline { + const obj = new fabric.Polyline( + [ + { x: OBJECT_DEFAULTS.left, y: OBJECT_DEFAULTS.top }, + { x: OBJECT_DEFAULTS.left + OBJECT_SIZE_DEFAULTS.width, y: OBJECT_DEFAULTS.top }, + ], + { ...OBJECT_DEFAULTS_VECTOR }, + ); + canvas.add(obj); + canvas.centerObjectV(obj); + return obj; + } + + static addCircle(canvas: fabric.Canvas): fabric.Circle { + const obj = new fabric.Circle({ + ...OBJECT_DEFAULTS_VECTOR, + radius: OBJECT_SIZE_DEFAULTS.width / 2, + }); + canvas.add(obj); + canvas.centerObjectV(obj); + return obj; + } + + static addRect(canvas: fabric.Canvas): fabric.Rect { + const obj = new fabric.Rect({ + ...OBJECT_SIZE_DEFAULTS, + ...OBJECT_DEFAULTS_VECTOR, + }); + canvas.add(obj); + canvas.centerObjectV(obj); + return obj; + } + + static addQrCode(canvas: fabric.Canvas): QRCode { + const qr = new QRCode({ + text: "NiimBlue", + ...OBJECT_SIZE_DEFAULTS, + ...OBJECT_DEFAULTS, + }); + canvas.add(qr); + return qr; + } + + static addArUco(canvas: fabric.Canvas): ArUcoMarker { + const aruco = new ArUcoMarker({ + ...OBJECT_SIZE_DEFAULTS, + ...OBJECT_DEFAULTS, + }); + canvas.add(aruco); + return aruco; + } + + static addBarcode(canvas: fabric.Canvas): Barcode { + const barcode = new Barcode({ + ...OBJECT_DEFAULTS, + text: "123456789012", + height: OBJECT_SIZE_DEFAULTS.height, + encoding: "CODE128B", + }); + canvas.add(barcode); + return barcode; + } + + static addObject(canvas: fabric.Canvas, objType: OjectType): fabric.FabricObject | undefined { + switch (objType) { + case "text": + return this.addText(canvas); + case "line": + return this.addHLine(canvas); + case "circle": + return this.addCircle(canvas); + case "rectangle": + return this.addRect(canvas); + case "image": + this.addImageWithFilePicker(canvas); + return; + case "qrcode": + return this.addQrCode(canvas); + case "aruco": + return this.addArUco(canvas); + case "barcode": + return this.addBarcode(canvas); + } + } +} + + + + + + + +
+
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ {#if selectedCount > 0} + + {/if} + + {#if selectedCount > 0} + + {/if} + + {#if selectedObject && selectedCount === 1} + + {/if} + + {#if selectedObject} + + {/if} + + {#if selectedObject instanceof fabric.IText} + + {/if} + + {#if selectedObject instanceof QRCode} + + {/if} + + {#if selectedObject instanceof ArUcoMarker} + + {/if} + + {#if selectedObject instanceof Barcode} + + {/if} + + {#if selectedObject instanceof fabric.IText || selectedObject instanceof QRCode || (selectedObject instanceof Barcode && selectedObject.encoding === "CODE128B")} + + {/if} +
+
+
+ + {#if previewOpened} + + {/if} +
+ + +
+ +
diff --git a/src/App.svelte b/src/App.svelte index b7e28df6..71d415b7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,25 +1,37 @@ - - {#if pdfImageSrc}

Success! PDF Received:

@@ -28,7 +40,7 @@
diff --git a/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java b/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java index c659a7be..2fc4fc82 100644 --- a/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java +++ b/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java @@ -1,81 +1,13 @@ package ru.mmote.niimblues; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.Color; -import android.graphics.pdf.PdfRenderer; -import android.net.Uri; import android.os.Bundle; -import android.os.ParcelFileDescriptor; -import android.util.Base64; -import android.util.Log; - import com.getcapacitor.BridgeActivity; -import java.io.ByteArrayOutputStream; - public class MainActivity extends BridgeActivity { - @Override - public void onResume() { - super.onResume(); - handleIntent(getIntent()); - } - - @Override - public void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - handleIntent(intent); - } - - private void handleIntent(Intent intent) { - // 1. Check if the app was opened via the "Share" menu with a PDF - if (Intent.ACTION_SEND.equals(intent.getAction()) && "application/pdf".equals(intent.getType())) { - Uri pdfUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (pdfUri != null) { - try { - // 2. Open the PDF file natively in Android - ParcelFileDescriptor fd = getContentResolver().openFileDescriptor(pdfUri, "r"); - PdfRenderer renderer = new PdfRenderer(fd); - - // Grab the first page (Page 0) - PdfRenderer.Page page = renderer.openPage(0); - - // 3. Convert the PDF page to a Bitmap Image - // The B1 printer is 203 DPI (roughly 400 pixels wide for standard labels) - int width = 400; - int height = (int) (width * ((float) page.getHeight() / page.getWidth())); - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - - // Fill the background with white (otherwise it might be transparent/black) - bitmap.eraseColor(Color.WHITE); - page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_PRINT); - - // 4. Compress the image to a Base64 String so JavaScript can read it - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); - byte[] imageBytes = baos.toByteArray(); - String base64Image = "data:image/png;base64," + Base64.encodeToString(imageBytes, Base64.NO_WRAP); - - // 5. Inject the image into the Javascript Frontend - if (bridge != null && bridge.getWebView() != null) { - String js = "window.dispatchEvent(new CustomEvent('pdfReceived', { detail: '" + base64Image + "' }));"; - bridge.getWebView().evaluateJavascript(js, null); - } - - // Clean up memory - page.close(); - renderer.close(); - fd.close(); - - // Remove the intent so it doesn't process twice - setIntent(new Intent()); - - } catch (Exception e) { - Log.e("NiimBlue PDF", "Error converting PDF to Image", e); - } - } - } + public void onCreate(Bundle savedInstanceState) { + // Register our custom local plugin + registerPlugin(PdfIntentPlugin.class); + super.onCreate(savedInstanceState); } } \ No newline at end of file diff --git a/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/PdfIntentPlugin.java b/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/PdfIntentPlugin.java new file mode 100644 index 00000000..f95d930e --- /dev/null +++ b/standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/PdfIntentPlugin.java @@ -0,0 +1,101 @@ +package ru.mmote.niimblues; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.pdf.PdfRenderer; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.util.Base64; +import android.util.Log; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.io.ByteArrayOutputStream; + +@CapacitorPlugin(name = "PdfIntent") +public class PdfIntentPlugin extends Plugin { + private String pendingPdfBase64 = null; + + @Override + public void load() { + // Called when the app does a "cold start" + processIntent(getActivity().getIntent()); + } + + @Override + protected void handleOnNewIntent(Intent intent) { + super.handleOnNewIntent(intent); + // Called when the app is already running in the background + processIntent(intent); + } + + // Svelte will call this when it mounts to grab any PDF that opened the app + @PluginMethod + public void getPending(PluginCall call) { + JSObject ret = new JSObject(); + if (pendingPdfBase64 != null) { + ret.put("image", pendingPdfBase64); + pendingPdfBase64 = null; // Clear it so it doesn't load twice + } + call.resolve(ret); + } + + private void processIntent(Intent intent) { + if (intent == null) return; + + String action = intent.getAction(); + String type = intent.getType(); + + // Check for both "Share" (SEND) and "Open With" (VIEW) + if ((Intent.ACTION_SEND.equals(action) || Intent.ACTION_VIEW.equals(action)) + && "application/pdf".equals(type)) { + + Uri pdfUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (pdfUri == null) { + pdfUri = intent.getData(); // ACTION_VIEW puts the Uri here + } + + if (pdfUri != null) { + try { + ParcelFileDescriptor fd = getContext().getContentResolver().openFileDescriptor(pdfUri, "r"); + if (fd == null) return; + PdfRenderer renderer = new PdfRenderer(fd); + PdfRenderer.Page page = renderer.openPage(0); + + // 800px width for better print quality (NiimBot is ~200-300dpi) + int width = 800; + int height = (int) (width * ((float) page.getHeight() / page.getWidth())); + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + + bitmap.eraseColor(Color.WHITE); + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_PRINT); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); + byte[] imageBytes = baos.toByteArray(); + String base64Image = "data:image/png;base64," + Base64.encodeToString(imageBytes, Base64.NO_WRAP); + + page.close(); + renderer.close(); + fd.close(); + + // 1. Store it so Svelte can fetch it when it boots up + pendingPdfBase64 = base64Image; + + // 2. Also broadcast it as an event (useful if the app was already open) + JSObject ret = new JSObject(); + ret.put("image", base64Image); + notifyListeners("onPdfReceived", ret); + + } catch (Exception e) { + Log.e("PdfIntentPlugin", "Error converting PDF to Image", e); + } + } + } + } +} \ No newline at end of file From e4ac62811c07b60cdc801c5ae0170318d0a63c44 Mon Sep 17 00:00:00 2001 From: Ryan Lyo <106399331+cryotato@users.noreply.github.com> Date: Fri, 29 May 2026 16:36:00 +0100 Subject: [PATCH 4/9] final --- GitHub (2).lnk | Bin 0 -> 1326 bytes repomix-output.xml | 1552 +++++++++-------- src/App.svelte | 45 +- src/components/LabelDesigner.svelte | 47 + .../java/ru/mmote/niimblues/MainActivity.java | 1 - .../ru/mmote/niimblues/PdfIntentPlugin.java | 141 +- standalone-apps/capacitor/capacitor.config.ts | 2 +- 7 files changed, 936 insertions(+), 852 deletions(-) create mode 100644 GitHub (2).lnk diff --git a/GitHub (2).lnk b/GitHub (2).lnk new file mode 100644 index 0000000000000000000000000000000000000000..ef809090f49f6044d257f36e59000695ea2d7c85 GIT binary patch literal 1326 zcma)6ZAepL6h2oiwbV8=QE6#KVV3jGmAZi;&CP+Ip@PBvVd})$=GSIQLR93BOtFYc z1jSO5fg(vC!b<#A{mDj!Xb6TxK_Oxw^&_F@T<2F7^R$&VJsE)}`-ZmHp-8Xid@?6yv( zm!k><7Zs9&EK~eDSZOE)(84c_a=g55X{kve^AhR3yiO^x zh3(Ai8_Yz$EH;eSb82Cgm$O}2%Qn)er1V@vVYcxCHfGzDvHdb<3>@S{D~HXqlPsux zDN;zMYAU6AvSSBTxIxwHjP@1!Y*B=K2z%6W$8$N{_qTl1tvxHy1N* zXR%@Q87C=&tl%}H282>LkPYks_~co~13a<-CBP@66*{}QRR~us-=Hj|^y<=jyQRwM zkXDQF!G);huG&V_15g9_+2sHfug4fnn#(%an@N~ z6JL9-PE)UQ((}0Cmt!4uInCM2B~#WB|4&T{Rp#kMWlPOFyFIQ~^{@9L?jaEQ&mgxZ zKQDh?eD>ZzS2G5FctZOe<@=4y$;8n>6tMM=U)H`>_KPBmnt1VhwQI+p_U!*kfqkWLc*`r%)2!u~n4q8fXpaAlWJ+ zlG|J-%OrSkK2d&{hsSk*ixC$OK3T|!QuHtS6t{MN9oz@|4tC&T{DIB=K(rgST>z!A z_^6HKKLc-ZZ3{tIn9xNw^l%K7qoZv&K>j{gqUFyx4U3Kc5j)mg&;82M*wkBZl#eE9 zJa;<6a|-sofLssP^!Ia?{%|wWmijrD4q≠r8^Qwi@$*IZfS{+Ssasy@DOg!0piq U)oe(A_L%X`Vo<^;SHCuX0kU}r!vFvP literal 0 HcmV?d00001 diff --git a/repomix-output.xml b/repomix-output.xml index 83809d03..f99b9001 100644 --- a/repomix-output.xml +++ b/repomix-output.xml @@ -115,6 +115,7 @@ standalone-apps/capacitor/android/app/capacitor.build.gradle standalone-apps/capacitor/android/app/proguard-rules.pro standalone-apps/capacitor/android/app/src/main/AndroidManifest.xml standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/MainActivity.java +standalone-apps/capacitor/android/app/src/main/java/ru/mmote/niimblues/PdfIntentPlugin.java standalone-apps/capacitor/android/build.gradle standalone-apps/capacitor/android/capacitor.settings.gradle standalone-apps/capacitor/android/gradle.properties @@ -166,54 +167,6 @@ CLAUDE.md } - -# --- Lockfiles & Dependencies --- -package-lock.json -yarn.lock -pnpm-lock.yaml - -# --- Media & Images (LLMs can't read code from images) --- -*.png -*.jpg -*.jpeg -*.ico -*.svg -*.webmanifest -*.lnk - -# --- Translations / Locales (Usually massive and irrelevant to logic) --- -src/locale/dicts/ - -# --- Generated / Asset Lists --- -# Assuming this is a massive list of Material Design Icons -src/styles/mdi_icons.ts -gen-mdi-list.mjs - -# --- CI/CD & Repository Metadata --- -.gitea/ -.github/ -.dockerignore -LICENSE - -# --- iOS Boilerplate & Assets --- -standalone-apps/capacitor/ios/App/App/Assets.xcassets/ -standalone-apps/capacitor/ios/App/App.xcodeproj/ -standalone-apps/capacitor/ios/App/App.xcworkspace/ -standalone-apps/capacitor/ios/App/App/Base.lproj/ - -# --- Android Boilerplate & Assets --- -standalone-apps/capacitor/android/app/src/main/res/ -standalone-apps/capacitor/android/gradle/ -standalone-apps/capacitor/android/gradlew -standalone-apps/capacitor/android/gradlew.bat -*.jar - -# --- Build Outputs (Just in case you generated them locally) --- -dist/ -build/ -.svelte-kit/ - - import eslint from "@eslint/js"; import svelte from "eslint-plugin-svelte"; @@ -2046,6 +1999,54 @@ export default config; } + +# --- Lockfiles & Dependencies --- +package-lock.json +yarn.lock +pnpm-lock.yaml + +# --- Media & Images (LLMs can't read code from images) --- +*.png +*.jpg +*.jpeg +*.ico +*.svg +*.webmanifest +*.lnk + +# --- Translations / Locales (Usually massive and irrelevant to logic) --- +src/locale/dicts/ + +# --- Generated / Asset Lists --- +# Assuming this is a massive list of Material Design Icons +src/styles/mdi_icons.ts +gen-mdi-list.mjs + +# --- CI/CD & Repository Metadata --- +.gitea/ +.github/ +.dockerignore +LICENSE + +# --- iOS Boilerplate & Assets --- +standalone-apps/capacitor/ios/App/App/Assets.xcassets/ +standalone-apps/capacitor/ios/App/App.xcodeproj/ +standalone-apps/capacitor/ios/App/App.xcworkspace/ +standalone-apps/capacitor/ios/App/App/Base.lproj/ + +# --- Android Boilerplate & Assets --- +standalone-apps/capacitor/android/app/src/main/res/ +standalone-apps/capacitor/android/gradle/ +standalone-apps/capacitor/android/gradlew +standalone-apps/capacitor/android/gradlew.bat +*.jar + +# --- Build Outputs (Just in case you generated them locally) --- +dist/ +build/ +.svelte-kit/ + + FROM node:25-alpine AS builder @@ -2066,45 +2067,6 @@ COPY --from=builder /app/dist/ /usr/share/nginx/html/ EXPOSE 80 - - - - - - - -{#if pdfImageSrc} -
-

Success! PDF Received:

- PDF converted to image - -
- -
-
-{/if} -
- + + + + +{#if pdfImageSrc} +
+

Success! PDF Received:

+ PDF converted to image +
+ +
+
+{/if} + + +{#if debugError} +
+

PDF Import Failed:

+

{debugError}

+ +
+{/if} +
+ + let { onSubmit, onSubmitSvg }: Props = $props(); -