diff --git a/deploy/prod/.gitignore b/deploy/prod/.gitignore index cb88020..7e17447 100644 --- a/deploy/prod/.gitignore +++ b/deploy/prod/.gitignore @@ -1,2 +1,3 @@ .secrets/ inventory.yml +.frontend-build/ diff --git a/deploy/prod/playbook.yml b/deploy/prod/playbook.yml index 7096962..d46c406 100644 --- a/deploy/prod/playbook.yml +++ b/deploy/prod/playbook.yml @@ -41,6 +41,13 @@ toolshed_backend_port: 8000 toolshed_wiki_dist_dir: /var/www/toolshed-wiki toolshed_local_dir: /var/www/toolshed-local + # The frontend build runs on the controller (see "Build frontend builder + # docker image (controller)" below) rather than the target host, so its + # scratch checkout and build output live here instead of under + # toolshed_src_dir/toolshed_dist_dir. Keyed by inventory_hostname so + # concurrent deploys to different hosts never collide. + toolshed_frontend_build_src_dir: "{{ playbook_dir }}/.frontend-build/{{ inventory_hostname }}/src" + toolshed_frontend_build_dist_dir: "{{ playbook_dir }}/.frontend-build/{{ inventory_hostname }}/dist" # Django's collectstatic output (admin/drf-yasg assets etc.), exported # from the built backend image so nginx can serve it directly instead of # proxying to gunicorn for every asset request. @@ -248,11 +255,14 @@ {% endif %} tasks: - - name: Install docker.io and nginx + - name: Install docker.io, nginx and rsync ansible.builtin.apt: name: - docker.io - nginx + # rsync is what the frontend dist sync (further down) relies on - + # it's the ansible.posix.synchronize module's transport. + - rsync state: present update_cache: true @@ -319,12 +329,18 @@ # until reloaded. notify: reload nginx + # Owned by ansible_user rather than www-data up front: the frontend dist + # sync below pushes files over a plain rsync-over-ssh connection as + # ansible_user (synchronize shells out to the local rsync binary, which + # opens its own ssh session - it doesn't go through Ansible's become), + # so that account needs write access here first. "Fix ownership of + # exported frontend build" resets this to www-data (via become) right + # after the sync completes. - name: Create frontend static output directory ansible.builtin.file: path: "{{ toolshed_dist_dir }}" state: directory - owner: www-data - group: www-data + owner: "{{ ansible_user }}" mode: "0755" - name: Create backend static output directory @@ -430,17 +446,56 @@ enabled: true state: started - - name: Build frontend builder docker image + # The next few tasks build the frontend on the controller instead of the + # target host: `npm run build` pulls in bootstrap+jquery+vue+moment+ + # js-nacl+qrcode, and esbuild's rendering/minification pass for that + # bundle needs more memory than small/memory-constrained target hosts + # (e.g. LXC containers without usable swap) reliably have. Only the + # resulting static dist/ is shipped to the target - the docker image + # itself never runs there. This assumes docker is already usable on the + # controller (not managed by this playbook, since "Install docker.io, + # nginx and rsync" above targets the remote host only). + - name: Checkout toolshed source (controller, for frontend build) + ansible.builtin.git: + repo: "{{ toolshed_repo_url | mandatory('toolshed_repo_url must be set as a host_var for ' ~ inventory_hostname) }}" + dest: "{{ toolshed_frontend_build_src_dir }}" + version: "{{ toolshed_version | default('stable') }}" + force: true + recursive: false + delegate_to: localhost + become: false + + - name: Build frontend builder docker image (controller) ansible.builtin.command: cmd: >- docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} - -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_src_dir }}/frontend + -f {{ toolshed_frontend_build_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_frontend_build_src_dir }}/frontend changed_when: true + delegate_to: localhost + become: false - - name: Run frontend builder once to export the static build + - name: Create local frontend dist scratch directory (controller) + ansible.builtin.file: + path: "{{ toolshed_frontend_build_dist_dir }}" + state: directory + mode: "0755" + delegate_to: localhost + become: false + + - name: Run frontend builder once to export the static build (controller) ansible.builtin.command: - cmd: docker run --rm -v {{ toolshed_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} + cmd: docker run --rm -v {{ toolshed_frontend_build_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} changed_when: true + delegate_to: localhost + become: false + + - name: Sync built frontend dist to the target host + ansible.posix.synchronize: + src: "{{ toolshed_frontend_build_dist_dir }}/" + dest: "{{ toolshed_dist_dir }}/" + delete: true + delegate_to: localhost + become: false - name: Fix ownership of exported frontend build ansible.builtin.file: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7de8ccb..704e559 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -569,6 +569,11 @@ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, "chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -609,11 +614,68 @@ "readdirp": "~3.6.0" } }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -621,8 +683,7 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "combined-stream": { "version": "1.0.8", @@ -725,6 +786,11 @@ "ms": "^2.1.3" } }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" + }, "decimal.js": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", @@ -746,6 +812,11 @@ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true }, + "dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "dns-query": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/dns-query/-/dns-query-0.11.2.tgz", @@ -855,6 +926,15 @@ "to-regex-range": "^5.0.1" } }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, "foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -883,6 +963,11 @@ "dev": true, "optional": true }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -992,8 +1077,7 @@ "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, "is-glob": { "version": "4.0.3", @@ -1104,6 +1188,14 @@ "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", "dev": true }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1230,6 +1322,29 @@ "yocto-queue": "^1.0.0" } }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + }, + "dependencies": { + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + } + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, "package-json-from-dist": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", @@ -1245,6 +1360,11 @@ "entities": "^4.4.0" } }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1295,6 +1415,11 @@ "pathe": "^1.1.2" } }, + "pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" + }, "popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -1353,6 +1478,16 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, + "qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "requires": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + } + }, "querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -1374,6 +1509,16 @@ "picomatch": "^2.2.1" } }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -1427,6 +1572,11 @@ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1823,6 +1973,11 @@ "isexe": "^2.0.0" } }, + "which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1916,6 +2071,68 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, "yocto-queue": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index bff1287..d4918b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dns-query": "^0.11.2", "js-nacl": "^1.4.0", "moment": "^2.29.4", + "qrcode": "^1.5.4", "vue": "^3.2.47", "vue-multiselect": "^2.1.7", "vue-router": "^4.1.6", diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index c55f002..de5fc9a 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -51,6 +51,12 @@ Swatch + diff --git a/frontend/src/main.js b/frontend/src/main.js index 1d95c89..80ea4ec 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -10,6 +10,8 @@ import store from './store'; import _nacl from 'js-nacl'; + + const app = createApp(App).use(store).use(BootstrapIconsPlugin); _nacl.instantiate((nacl) => { diff --git a/frontend/src/router.js b/frontend/src/router.js index ab9f4c6..30a99b2 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -20,6 +20,7 @@ import StorageLocationEdit from '@/views/StorageLocationEdit.vue'; import Admin from '@/views/Admin.vue'; import Swatch from '@/views/Swatch.vue'; import Files from '@/views/Files.vue'; +import Print from "@/views/Print.vue"; import Workflows from '@/views/Workflows.vue'; import WorkflowDetail from '@/views/WorkflowDetail.vue'; @@ -67,7 +68,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { }, {path: '/admin', component: Admin, meta: {requiresAuth: true} -}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, { +}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {path: '/print', component: Print, meta: {requiresAuth: true}}, { path: '/search/:query', component: Search, meta: {requiresAuth: true}, diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue new file mode 100644 index 0000000..1b4724f --- /dev/null +++ b/frontend/src/views/Print.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/frontend/vendor/libweblabel.mjs b/frontend/vendor/libweblabel.mjs new file mode 100644 index 0000000..a2b7eee --- /dev/null +++ b/frontend/vendor/libweblabel.mjs @@ -0,0 +1,2 @@ +async function createPrinterBlob(moduleArg={}){var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("node:module");var require=createRequire(import.meta.url)}var programArgs=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("node:path").dirname(require("node:url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}programArgs=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var runtimeInitialized=false;function getMemoryBuffer(){return wasmMemory.buffer}function updateMemoryViews(){if(HEAP8?.buffer?.resizable)return;var b=getMemoryBuffer();HEAP8=new Int8Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b)}function preRun(){var preRun=Module["preRun"];if(preRun){if(typeof preRun=="function")preRun=[preRun];onPreRuns.push(...preRun)}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["__wasm_call_ctors"]()}function postRun(){var postRun=Module["postRun"];if(postRun){if(typeof postRun=="function")postRun=[postRun];onPostRuns.push(...postRun)}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("libweblabel.wasm")}return new URL("libweblabel.wasm",import.meta.url).href}function getBinarySync(file){if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={env:wasmImports,wasi_snapshot_preview1:wasmImports};return imports}async function createWasm(){function receiveInstance(instance){wasmExports=instance.exports;wasmExports=Asyncify.instrumentWasmExports(wasmExports);assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();var instantiateWasm=Module["instantiateWasm"];if(instantiateWasm){return new Promise(resolve=>{instantiateWasm(info,inst=>resolve(receiveInstance(inst)))})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var onPreRuns=[];var dynCalls={};var dynCallLegacy=(sig,ptr,args)=>{sig=sig.replace(/p/g,"i");var f=dynCalls[sig];return f(ptr,...args)};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var HEAPU8;var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _fd_close=fd=>52;var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function _fd_seek(fd,offset,whence,newOffset){offset=bigintToI53Checked(offset);return 70}var printCharBuffers=[null,[],[]];var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var printChar=(stream,curr)=>{var buffer=printCharBuffers[stream];if(!curr||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer));buffer.length=0}else{buffer.push(curr)}};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var HEAPU32;var _fd_write=(fd,iov,iovcnt,pnum)=>{var num=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;for(var j=0;j>2]=num;return 0};var runAndAbortIfError=func=>{try{return func()}catch(e){abort(e)}};var handleException=e=>{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e)};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;var maybeExit=()=>{if(!keepRuntimeAlive()){try{_exit(EXITSTATUS)}catch(e){handleException(e)}}};var callUserCallback=func=>{if(ABORT){return}try{return func()}catch(e){handleException(e)}finally{maybeExit()}};var runtimeKeepalivePush=()=>{runtimeKeepaliveCounter+=1};var runtimeKeepalivePop=()=>{runtimeKeepaliveCounter-=1};var HEAP32;var Asyncify={instrumentWasmImports(imports){var importPattern=/^(invoke_.*|__asyncjs__.*)$/;for(let[x,original]of Object.entries(imports)){if(typeof original=="function"){let isAsyncifyImport=original.isAsync||importPattern.test(x)}}},instrumentFunction(original){var wrapper=(...args)=>{Asyncify.exportCallStack.push(original);try{return original(...args)}finally{if(!ABORT){var top=Asyncify.exportCallStack.pop();Asyncify.maybeStopUnwind()}}};Asyncify.funcWrappers.set(original,wrapper);return wrapper},instrumentWasmExports(exports){var ret={};for(let[x,original]of Object.entries(exports)){if(typeof original=="function"){var wrapper=Asyncify.instrumentFunction(original);ret[x]=wrapper}else{ret[x]=original}}return ret},State:{Normal:0,Unwinding:1,Rewinding:2,Disabled:3},state:0,StackSize:16384,currData:null,handleSleepReturnValue:0,exportCallStack:[],callstackFuncToId:new Map,callStackIdToFunc:new Map,funcWrappers:new Map,callStackId:0,asyncPromiseHandlers:null,sleepCallbacks:[],getCallStackId(func){if(!Asyncify.callstackFuncToId.has(func)){var id=Asyncify.callStackId++;Asyncify.callstackFuncToId.set(func,id);Asyncify.callStackIdToFunc.set(id,func)}return Asyncify.callstackFuncToId.get(func)},maybeStopUnwind(){if(Asyncify.currData&&Asyncify.state===Asyncify.State.Unwinding&&!Asyncify.exportCallStack.length){Asyncify.state=Asyncify.State.Normal;runAndAbortIfError(_asyncify_stop_unwind);if(typeof Fibers!="undefined"){Fibers.trampoline()}}},whenDone(){return new Promise((resolve,reject)=>{Asyncify.asyncPromiseHandlers={resolve,reject}})},allocateData(){var ptr=_malloc(12+Asyncify.StackSize);Asyncify.setDataHeader(ptr,ptr+12,Asyncify.StackSize);Asyncify.setDataRewindFunc(ptr);return ptr},setDataHeader(ptr,stack,stackSize){HEAPU32[ptr>>2]=stack;HEAPU32[ptr+4>>2]=stack+stackSize},setDataRewindFunc(ptr){var bottomOfCallStack=Asyncify.exportCallStack[0];var rewindId=Asyncify.getCallStackId(bottomOfCallStack);HEAP32[ptr+8>>2]=rewindId},getDataRewindFunc(ptr){var id=HEAP32[ptr+8>>2];var func=Asyncify.callStackIdToFunc.get(id);return func},doRewind(ptr){var original=Asyncify.getDataRewindFunc(ptr);var func=Asyncify.funcWrappers.get(original);return callUserCallback(func)},handleSleep(startAsync){if(ABORT)return;if(Asyncify.state===Asyncify.State.Normal){var reachedCallback=false;var reachedAfterCallback=false;startAsync((handleSleepReturnValue=0)=>{if(ABORT)return;Asyncify.handleSleepReturnValue=handleSleepReturnValue;reachedCallback=true;if(!reachedAfterCallback){return}Asyncify.state=Asyncify.State.Rewinding;runAndAbortIfError(()=>_asyncify_start_rewind(Asyncify.currData));if(typeof MainLoop!="undefined"&&MainLoop.func){MainLoop.resume()}var asyncWasmReturnValue,isError=false;try{asyncWasmReturnValue=Asyncify.doRewind(Asyncify.currData)}catch(err){asyncWasmReturnValue=err;isError=true}var handled=false;if(!Asyncify.currData){var asyncPromiseHandlers=Asyncify.asyncPromiseHandlers;if(asyncPromiseHandlers){Asyncify.asyncPromiseHandlers=null;(isError?asyncPromiseHandlers.reject:asyncPromiseHandlers.resolve)(asyncWasmReturnValue);handled=true}}if(isError&&!handled){throw asyncWasmReturnValue}});reachedAfterCallback=true;if(!reachedCallback){Asyncify.state=Asyncify.State.Unwinding;Asyncify.currData=Asyncify.allocateData();if(typeof MainLoop!="undefined"&&MainLoop.func){MainLoop.pause()}runAndAbortIfError(()=>_asyncify_start_unwind(Asyncify.currData))}}else if(Asyncify.state===Asyncify.State.Rewinding){Asyncify.state=Asyncify.State.Normal;runAndAbortIfError(_asyncify_stop_rewind);_free(Asyncify.currData);Asyncify.currData=null;Asyncify.sleepCallbacks.forEach(callUserCallback)}else{abort(`invalid state: ${Asyncify.state}`)}return Asyncify.handleSleepReturnValue},handleAsync:startAsync=>Asyncify.handleSleep(async wakeUp=>{wakeUp(await startAsync())})};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["arguments"])programArgs=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var preInit=Module["preInit"];if(preInit){if(typeof preInit=="function")Module["preInit"]=preInit=[preInit];while(preInit.length>0){preInit.shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["UTF8ToString"]=UTF8ToString;function __asyncjs__pwusb_list(out,max){return Asyncify.handleAsync(async()=>{const devices=await Module.usbBackend.list(max);const view=new DataView(HEAPU8.buffer);let n=0;for(const d of devices){if(n>=max)break;const base=out+n*6;view.setUint16(base+0,d.vendorId,true);view.setUint16(base+2,d.productId,true);view.setUint8(base+4,d.busNumber||0);view.setUint8(base+5,d.deviceAddress||0);++n}return n})}function __asyncjs__pwusb_open(index){return Asyncify.handleAsync(async()=>await Module.usbBackend.open(index))}function __asyncjs__pwusb_close(handle){return Asyncify.handleAsync(async()=>{await Module.usbBackend.close(handle)})}function __asyncjs__pwusb_claim(handle,interface_number){return Asyncify.handleAsync(async()=>await Module.usbBackend.claim(handle,interface_number))}function __asyncjs__pwusb_release(handle,interface_number){return Asyncify.handleAsync(async()=>await Module.usbBackend.release(handle,interface_number))}function __asyncjs__pwusb_out(handle,endpoint,data,len){return Asyncify.handleAsync(async()=>{const bytes=HEAPU8.slice(data,data+len);return await Module.usbBackend.transferOut(handle,endpoint,bytes)})}function __asyncjs__pwusb_in(handle,endpoint,data,len){return Asyncify.handleAsync(async()=>{const result=await Module.usbBackend.transferIn(handle,endpoint,len);if(typeof result==="number"){return result}const n=Math.min(result.length,len);HEAPU8.set(result.subarray(0,n),data);return n})}function __asyncjs__pwusb_sleep_ms(ms){return Asyncify.handleAsync(async()=>{await new Promise(resolve=>setTimeout(resolve,ms))})}var _pw_manifest,_pw_ptouch_manifest,_pw_ql570_manifest,_pw_ptouch_devices,_pw_ql570_devices,_pw_ptouch_capabilities,_pw_ql570_capabilities,_pw_devices,_pw_capabilities,_pw_driver_for,_pw_ptouch_supports,_pw_ql570_supports,_pw_driver_count,_pw_supports,_pw_abi_version,_pw_last_error,_pw_open,_pw_close,_pw_status_json,_pw_print,_pw_ptouch_last_error,_pw_ptouch_status_json,_pw_ptouch_abi_version,_pw_ptouch_open,_pw_ptouch_close,_pw_ptouch_print,_pw_ql570_last_error,_pw_ql570_status_json,_pw_ql570_abi_version,_pw_ql570_open,_pw_ql570_close,_pw_ql570_print,_malloc,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,dynCall_i,dynCall_ii,dynCall_iii,dynCall_iiii,dynCall_iiiiiii,dynCall_jiji,dynCall_iidiiiii,dynCall_vii,_asyncify_start_unwind,_asyncify_stop_unwind,_asyncify_start_rewind,_asyncify_stop_rewind,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_pw_manifest=Module["_pw_manifest"]=wasmExports["pw_manifest"];_pw_ptouch_manifest=Module["_pw_ptouch_manifest"]=wasmExports["pw_ptouch_manifest"];_pw_ql570_manifest=Module["_pw_ql570_manifest"]=wasmExports["pw_ql570_manifest"];_pw_ptouch_devices=Module["_pw_ptouch_devices"]=wasmExports["pw_ptouch_devices"];_pw_ql570_devices=Module["_pw_ql570_devices"]=wasmExports["pw_ql570_devices"];_pw_ptouch_capabilities=Module["_pw_ptouch_capabilities"]=wasmExports["pw_ptouch_capabilities"];_pw_ql570_capabilities=Module["_pw_ql570_capabilities"]=wasmExports["pw_ql570_capabilities"];_pw_devices=Module["_pw_devices"]=wasmExports["pw_devices"];_pw_capabilities=Module["_pw_capabilities"]=wasmExports["pw_capabilities"];_pw_driver_for=Module["_pw_driver_for"]=wasmExports["pw_driver_for"];_pw_ptouch_supports=Module["_pw_ptouch_supports"]=wasmExports["pw_ptouch_supports"];_pw_ql570_supports=Module["_pw_ql570_supports"]=wasmExports["pw_ql570_supports"];_pw_driver_count=Module["_pw_driver_count"]=wasmExports["pw_driver_count"];_pw_supports=Module["_pw_supports"]=wasmExports["pw_supports"];_pw_abi_version=Module["_pw_abi_version"]=wasmExports["pw_abi_version"];_pw_last_error=Module["_pw_last_error"]=wasmExports["pw_last_error"];_pw_open=Module["_pw_open"]=wasmExports["pw_open"];_pw_close=Module["_pw_close"]=wasmExports["pw_close"];_pw_status_json=Module["_pw_status_json"]=wasmExports["pw_status_json"];_pw_print=Module["_pw_print"]=wasmExports["pw_print"];_pw_ptouch_last_error=Module["_pw_ptouch_last_error"]=wasmExports["pw_ptouch_last_error"];_pw_ptouch_status_json=Module["_pw_ptouch_status_json"]=wasmExports["pw_ptouch_status_json"];_pw_ptouch_abi_version=Module["_pw_ptouch_abi_version"]=wasmExports["pw_ptouch_abi_version"];_pw_ptouch_open=Module["_pw_ptouch_open"]=wasmExports["pw_ptouch_open"];_pw_ptouch_close=Module["_pw_ptouch_close"]=wasmExports["pw_ptouch_close"];_pw_ptouch_print=Module["_pw_ptouch_print"]=wasmExports["pw_ptouch_print"];_pw_ql570_last_error=Module["_pw_ql570_last_error"]=wasmExports["pw_ql570_last_error"];_pw_ql570_status_json=Module["_pw_ql570_status_json"]=wasmExports["pw_ql570_status_json"];_pw_ql570_abi_version=Module["_pw_ql570_abi_version"]=wasmExports["pw_ql570_abi_version"];_pw_ql570_open=Module["_pw_ql570_open"]=wasmExports["pw_ql570_open"];_pw_ql570_close=Module["_pw_ql570_close"]=wasmExports["pw_ql570_close"];_pw_ql570_print=Module["_pw_ql570_print"]=wasmExports["pw_ql570_print"];_malloc=Module["_malloc"]=wasmExports["malloc"];_free=Module["_free"]=wasmExports["free"];__emscripten_stack_restore=wasmExports["_emscripten_stack_restore"];__emscripten_stack_alloc=wasmExports["_emscripten_stack_alloc"];_emscripten_stack_get_current=wasmExports["emscripten_stack_get_current"];dynCall_i=dynCalls["i"]=wasmExports["dynCall_i"];dynCall_ii=dynCalls["ii"]=wasmExports["dynCall_ii"];dynCall_iii=dynCalls["iii"]=wasmExports["dynCall_iii"];dynCall_iiii=dynCalls["iiii"]=wasmExports["dynCall_iiii"];dynCall_iiiiiii=dynCalls["iiiiiii"]=wasmExports["dynCall_iiiiiii"];dynCall_jiji=dynCalls["jiji"]=wasmExports["dynCall_jiji"];dynCall_iidiiiii=dynCalls["iidiiiii"]=wasmExports["dynCall_iidiiiii"];dynCall_vii=dynCalls["vii"]=wasmExports["dynCall_vii"];_asyncify_start_unwind=wasmExports["asyncify_start_unwind"];_asyncify_stop_unwind=wasmExports["asyncify_stop_unwind"];_asyncify_start_rewind=wasmExports["asyncify_start_rewind"];_asyncify_stop_rewind=wasmExports["asyncify_stop_rewind"];memory=wasmMemory=wasmExports["memory"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={__asyncjs__pwusb_claim,__asyncjs__pwusb_close,__asyncjs__pwusb_in,__asyncjs__pwusb_list,__asyncjs__pwusb_open,__asyncjs__pwusb_out,__asyncjs__pwusb_release,__asyncjs__pwusb_sleep_ms,emscripten_resize_heap:_emscripten_resize_heap,fd_close:_fd_close,fd_seek:_fd_seek,fd_write:_fd_write};async function run(){preRun();var setStatus=Module["setStatus"];if(setStatus){setStatus("Running...");await new Promise(resolve=>setTimeout(resolve,1));setTimeout(setStatus,1,"")}if(ABORT)return;initRuntime();Module["onRuntimeInitialized"]?.();postRun()}var wasmExports;wasmExports=await createWasm();await run(); +;return Module}export default createPrinterBlob; diff --git a/frontend/vendor/libweblabel.wasm b/frontend/vendor/libweblabel.wasm new file mode 100644 index 0000000..7ba37e1 Binary files /dev/null and b/frontend/vendor/libweblabel.wasm differ diff --git a/frontend/vendor/weblabel.mjs b/frontend/vendor/weblabel.mjs new file mode 100644 index 0000000..79c65f1 --- /dev/null +++ b/frontend/vendor/weblabel.mjs @@ -0,0 +1,958 @@ +/* + libweblabel — generated bundle, do not edit. + + Built by tools/bundle.py from src/web/index.js and the modules it re-exports: + src/web/lib/blob.js + src/web/lib/bitmap.js + src/web/lib/text.js + src/web/lib/pattern.js + src/web/lib/ruler.js + + Edit those and rebuild. The wasm driver blobs are separate files loaded at + runtime; see dist/blobs/. +*/ + +/* ---- src/web/lib/blob.js ------------------------------------------------- */ + +/* + Loader for USB printer blobs (ABI v1). + + Deliberately knows nothing about P-touch printers, Brother, or libptouch. + It loads a wasm blob, reads the device table out of the blob's manifest and + calls the six pw_* entry points. A blob for a different vendor's printers, + built against the same shim and exporting the same ABI, is driven by this + file unchanged - and so is a future version of this driver that supports + more printers, because the device table travels in the manifest rather than + being duplicated here. + + Two classes, and the difference is smaller than it looks: + + PrinterBlob any blob, holding one driver or many. + MultiPrinterBlob the same, seen as the set of drivers inside it: which + one owns a given printer, what that one alone can do, + and which of its exports to call. A merged blob + (dist/blobs/libweblabel.mjs) is what makes those + questions interesting; PrinterBlob drives it either + way. + + The ABI is described in docs/ABI.md. +*/ + +/* ------------------------------------------------------------------------ + WebUSB backend + + The JS half of the shim's contract. Also vendor-neutral: it moves bytes and + has no idea what they mean. + ------------------------------------------------------------------------ */ + +export class WebUsbBackend { + constructor({ transferTimeoutMs = 5000 } = {}) { + this.transferTimeoutMs = transferTimeoutMs; + this.devices = []; /* the devices the blob is allowed to see */ + this.handles = new Map(); + this.nextHandle = 1; + } + + /* Scope the blob to specific devices. Drivers typically open the first + match in the list, so passing exactly the device the user picked in the + chooser is what makes the selection stick. */ + setDevices(devices) { + this.devices = devices; + } + + async list() { + return this.devices.map((d, index) => ({ + vendorId: d.vendorId, + productId: d.productId, + busNumber: 0, /* WebUSB does not expose bus topology */ + deviceAddress: index, + })); + } + + async open(index) { + const device = this.devices[index]; + if (!device) { + return -1; + } + try { + if (!device.opened) { + await device.open(); + } + if (device.configuration === null) { + await device.selectConfiguration(1); + } + } catch (e) { + this.lastError = e; + return -1; + } + const handle = this.nextHandle++; + this.handles.set(handle, device); + return handle; + } + + async close(handle) { + const device = this.handles.get(handle); + this.handles.delete(handle); + if (device && device.opened) { + try { + await device.close(); + } catch { /* the device may already be gone */ } + } + } + + async claim(handle, interfaceNumber) { + const device = this.handles.get(handle); + if (!device) return -1; + try { + await device.claimInterface(interfaceNumber); + return 0; + } catch (e) { + /* A driver only learns that the claim failed, not why. WebUSB's + own message is the useful part, and on Linux the overwhelmingly + likely cause is a bound kernel driver - usblp binds anything of + printer class - which the browser cannot detach. */ + let detail = `claimInterface(${interfaceNumber}) failed: ${e.message}`; + if (typeof navigator !== "undefined" && /Linux/.test(navigator.userAgent || "")) { + detail += ". On Linux this usually means a kernel driver holds the " + + "interface (usblp claims printer-class devices) and the browser " + + "cannot detach it. Unbind it: " + + "echo -n | sudo tee /sys/bus/usb/drivers/usblp/unbind " + + "— see drivers/ptouch/README.md for the persistent udev rule"; + } + this.lastError = new Error(detail); + return -1; + } + } + + /* Hand the most recent underlying failure to the caller, once. */ + takeLastError() { + const e = this.lastError; + this.lastError = null; + return e ? e.message : null; + } + + async release(handle, interfaceNumber) { + const device = this.handles.get(handle); + if (!device) return -1; + try { + await device.releaseInterface(interfaceNumber); + return 0; + } catch (e) { + this.lastError = e; + return -1; + } + } + + async transferOut(handle, endpoint, bytes) { + const device = this.handles.get(handle); + if (!device) return -1; + try { + const result = await device.transferOut(endpoint, bytes); + if (result.status !== "ok") { + this.lastError = new Error(`transferOut: ${result.status}`); + return -1; + } + return result.bytesWritten; + } catch (e) { + this.lastError = e; + return -1; + } + } + + async transferIn(handle, endpoint, length) { + const device = this.handles.get(handle); + if (!device) return -1; + /* WebUSB has no per-transfer timeout and a pending transferIn cannot be + cancelled, so race it against a timer. -7 is LIBUSB_ERROR_TIMEOUT to + the shim, which is what the driver's own timeout handling expects. */ + const TIMEOUT = Symbol("timeout"); + let timer; + try { + const result = await Promise.race([ + device.transferIn(endpoint, length), + new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMEOUT), this.transferTimeoutMs); + }), + ]); + if (result === TIMEOUT) { + /* The abandoned transfer would otherwise deliver its data into + the next read. Clearing the endpoint discards it. */ + try { await device.clearHalt("in", endpoint); } catch { /* best effort */ } + this.lastError = new Error(`transferIn timed out after ${this.transferTimeoutMs} ms`); + return -7; + } + if (result.status !== "ok") { + this.lastError = new Error(`transferIn: ${result.status}`); + return -1; + } + return new Uint8Array(result.data.buffer, result.data.byteOffset, result.data.byteLength); + } catch (e) { + this.lastError = e; + return -1; + } finally { + clearTimeout(timer); + } + } +} + +/* ------------------------------------------------------------------------ + Blob + ------------------------------------------------------------------------ */ + +const MAX_LOG = 500; + +export class PrinterBlob { + constructor(module, backend) { + this._module = module; + this._backend = backend; + this._queue = Promise.resolve(); + this.log = []; + this.manifest = null; + this.abiVersion = 0; + this.openDevice = null; /* {vendorId, productId} while open */ + } + + /* + url location of the blob's .mjs glue, resolved against baseUrl + backend anything implementing the backend contract; defaults to WebUSB + baseUrl base for resolving url (defaults to the document / this module) + */ + static async load(url, { backend, baseUrl } = {}) { + const base = baseUrl + || (typeof document !== "undefined" ? document.baseURI : import.meta.url); + const href = new URL(url, base).href; + + const usbBackend = backend || new WebUsbBackend(); + /* `new this`, so a subclass reaching this through super.load() gets an + instance of itself. */ + const blob = new this(null, usbBackend); + + const factory = (await import(/* @vite-ignore */ href)).default; + blob._module = await factory({ + usbBackend, + print: (text) => blob._record("out", text), + printErr: (text) => blob._record("err", text), + }); + + blob.abiVersion = await blob._call("pw_abi_version", "number", [], []); + if (blob.abiVersion !== 1) { + throw new Error(`unsupported blob ABI version ${blob.abiVersion}, expected 1`); + } + const manifest = await blob._call("pw_manifest", "string", [], []); + blob.manifest = JSON.parse(manifest); + return blob; + } + + _record(stream, text) { + this.log.push({ stream, text }); + if (this.log.length > MAX_LOG) { + this.log.shift(); + } + } + + /* Asyncify unwinds a single wasm stack at a time, so every export call is + serialized. Without this, two overlapping calls corrupt each other. */ + _call(name, returnType, argTypes, args) { + const run = () => this._module.ccall(name, returnType, argTypes, args, { async: true }); + const result = this._queue.then(run, run); + this._queue = result.then(() => undefined, () => undefined); + return result; + } + + /* + Call an export by name, through the same queue everything else uses. + + The methods below cover the ABI; this is for the exports they do not, + which is how a caller uses the per-driver symbols that + MultiPrinterBlob.symbolsFor() names. Going around it and calling + Module.ccall() directly is what re-entering a suspended blob looks like. + */ + callExport(name, returnType = "number", argTypes = [], args = []) { + return this._call(name, returnType, argTypes, args); + } + + /* The driver's own explanation, plus whatever the backend knows about the + host-level cause. The driver only sees "the claim failed"; the backend + is the layer that saw the actual exception. */ + async _lastError() { + const fromDriver = await this._call("pw_last_error", "string", [], []); + const fromBackend = typeof this._backend.takeLastError === "function" + ? this._backend.takeLastError() : null; + return [fromDriver, fromBackend].filter(Boolean).join(" — ") || "unknown error"; + } + + /* + Device filters for navigator.usb.requestDevice(), straight from the + blob. Deduplicated by USB id: a merged blob's table is several drivers' + tables at once, and two of them may know the same printer. + */ + usbFilters() { + const seen = new Set(); + const filters = []; + for (const d of this.manifest.devices) { + const key = `${d.vendorId}:${d.productId}`; + if (!seen.has(key)) { + seen.add(key); + filters.push({ vendorId: d.vendorId, productId: d.productId }); + } + } + return filters; + } + + /** What the blob knows about these USB ids, or null if it does not know them. */ + findDevice(vendorId, productId) { + return this.manifest.devices.find( + (d) => d.vendorId === vendorId && d.productId === productId) || null; + } + + /** Restrict the blob's view of the bus to these WebUSB devices. */ + setDevices(devices) { + this._backend.setDevices(devices); + } + + async open(vendorId, productId, { timeoutSeconds = 1 } = {}) { + const rc = await this._call("pw_open", "number", + ["number", "number", "number"], [vendorId, productId, timeoutSeconds]); + if (rc !== 0) { + throw new Error(await this._lastError()); + } + this.openDevice = { vendorId, productId }; + } + + async status({ timeoutSeconds = 1 } = {}) { + const json = await this._call("pw_status_json", "string", + ["number"], [timeoutSeconds]); + const parsed = JSON.parse(json); + if (parsed === null) { + throw new Error(await this._lastError()); + } + return parsed; + } + + async close() { + await this._call("pw_close", "number", [], []); + this.openDevice = null; + } + + /** Does the blob advertise this capability? */ + can(capability) { + return (this.manifest.capabilities || []).includes(capability); + } + + /* + Print a bitmap: one byte per pixel, row-major, non-zero = a printed dot. + width runs along the tape, height across it. The caller is responsible + for keeping height within the print area the status reports. + */ + async printBitmap({ data, width, height }, + { chain = false, precut = false, copies = 1 } = {}) { + if (!this.can("print")) { + throw new Error("this blob does not support printing"); + } + if (data.length !== width * height) { + throw new Error(`bitmap is ${data.length} bytes, expected ${width * height}`); + } + /* Staged into the wasm heap so the blob can read it directly. Freed + even if the print throws, and after the call has fully finished - + the pointer is live for the whole suspended-stack duration. */ + const ptr = this._module._malloc(data.length); + if (!ptr) { + throw new Error(`could not allocate ${data.length} bytes in the blob`); + } + try { + this._module.HEAPU8.set(data, ptr); + const rc = await this._call("pw_print", "number", + ["number", "number", "number", "number", "number", "number"], + [ptr, width, height, chain ? 1 : 0, precut ? 1 : 0, copies]); + if (rc !== 0) { + throw new Error(await this._lastError()); + } + } finally { + this._module._free(ptr); + } + } +} + +/* ------------------------------------------------------------------------ + Blobs seen as a set of drivers + + dist/blobs/libweblabel.mjs is every driver this build produced, in one + module. It forwards the plain ABI to whichever driver owns the printer that + was opened, so PrinterBlob drives it without knowing that. + + What this class covers is the part only the driver set can answer: which + driver a given printer belongs to, what that driver alone can do, and which + of its exports to call for it. A blob holding one driver is a set of one, so + this works on those too and a page never has to choose between the two + classes. See "Merged blobs" in docs/ABI.md. + ------------------------------------------------------------------------ */ + +/* The ABI, as the JavaScript name for each export and the bare C name it is + built from. A merged blob exports every one of these twice: once plain, and + once per driver as pw__. */ +const ABI_EXPORTS = { + abiVersion: "abi_version", + manifest: "manifest", + devices: "devices", + capabilities: "capabilities", + supports: "supports", + open: "open", + close: "close", + statusJson: "status_json", + print: "print", + lastError: "last_error", +}; + +export class MultiPrinterBlob extends PrinterBlob { + /* + Does this module hold several drivers? + + The merged blob reports them under "drivers" and reaches each one + through prefixed exports; a single-driver blob has only the plain ones. + It is the one thing that changes the answers below. + */ + get merged() { + return Array.isArray(this.manifest.drivers); + } + + /* The drivers inside this blob, each one's own manifest verbatim. A + single-driver blob's manifest has that shape already, so it is a set of + one rather than a special case. */ + get drivers() { + return this.manifest.drivers || [this.manifest]; + } + + /** Their names, in the order the build linked them. */ + driverNames() { + return this.drivers.map((d) => d.driver.name); + } + + /** The manifest of the driver with this name, or null. */ + driver(name) { + return this.drivers.find((d) => d.driver.name === name) || null; + } + + /* + The driver that owns these USB ids, or null if none does. + + Answered from the merged device table, where every entry names the + driver it came from. pw_driver_for() inside the blob answers the same + question from the same tables; this is the cheap synchronous way to ask + it. + */ + driverFor(vendorId, productId) { + const device = this.findDevice(vendorId, productId); + return device ? this.driver(device.driver) : null; + } + + /* + Which exports to call for this printer. + + const { driver, exports } = blob.symbolsFor(0x04f9, 0x2074); + await blob.callExport(exports.open, "number", + ["number", "number", "number"], [0x04f9, 0x2074, 1]); + + Going through the plain ABI - blob.open(), blob.status(), and the rest + - does the same dispatch inside the blob and is what most callers want. + This is for a caller that has a reason to address one driver directly: + reading a second driver's manifest while a printer is open, say, or + driving two of them without letting either one's dispatch state decide + which is current. + + On a single-driver blob the prefix is empty and these are the plain ABI + names, which is the truth there: the one driver is the dispatch. + + Returns null when no driver in this blob knows the device. + */ + symbolsFor(vendorId, productId) { + const driver = this.driverFor(vendorId, productId); + if (!driver) { + return null; + } + const name = driver.driver.name; + const prefix = this.merged ? `pw_${name}_` : "pw_"; + const exports = {}; + for (const [key, bare] of Object.entries(ABI_EXPORTS)) { + exports[key] = prefix + bare; + } + return { driver: name, prefix, exports, capabilities: driver.capabilities }; + } + + /* + Does a capability apply? + + With no device, the answer is the merged one: some driver in this blob + can do it. With a device - or with one open - it is that device's + driver alone, which is the honest answer for a control that is about to + act on that printer. "chain" is the case that matters: the P-touch + driver honours it and the QL driver does not. + */ + can(capability, device = this.openDevice) { + if (!device) { + return super.can(capability); + } + const driver = this.driverFor(device.vendorId, device.productId); + return Boolean(driver && (driver.capabilities || []).includes(capability)); + } +} + +/* ---- src/web/lib/bitmap.js ----------------------------------------------- */ + +/* + Canvas ⇄ the one-byte-per-pixel bitmap the blob ABI takes. + + A printhead has one state per dot: a pin either fires or it does not. Canvas + drawing is anti-aliased, so everything drawn there is greyscale until it is + thresholded here. Nothing in this file knows what is being printed. +*/ + +/* Canvas RGBA to the bitmap: one byte per pixel, row-major, non-zero = a dot. */ +export function canvasToBitmap(canvas) { + const { width, height } = canvas; + const rgba = canvas.getContext("2d", { willReadFrequently: true }) + .getImageData(0, 0, width, height).data; + const data = new Uint8Array(width * height); + for (let i = 0, p = 0; p < data.length; i += 4, ++p) { + /* Rounded, because the coefficients do not sum to exactly 1 in binary + floating point: without this, a pixel at exactly mid grey flips on + rounding noise instead of landing consistently on one side. */ + const luminance = Math.round(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]); + /* Transparent counts as blank; anything darker than mid grey prints. */ + data[p] = (rgba[i + 3] > 127 && luminance < 128) ? 1 : 0; + } + return { data, width, height }; +} + +/* + Paint a bitmap back onto its canvas, so the preview shows exactly the dots + that will be printed, jaggies and all. canvasToBitmap() is idempotent over + this: re-reading the canvas afterwards yields the same bytes. +*/ +export function bitmapToCanvas(canvas, bitmap) { + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + const img = ctx.createImageData(bitmap.width, bitmap.height); + for (let p = 0, i = 0; p < bitmap.data.length; ++p, i += 4) { + const value = bitmap.data[p] ? 0 : 255; + img.data[i] = img.data[i + 1] = img.data[i + 2] = value; + img.data[i + 3] = 255; + } + ctx.putImageData(img, 0, 0); +} + +/** How many dots the printhead will fire for this bitmap. */ +export function countDots(bitmap) { + let dots = 0; + for (const b of bitmap.data) { + dots += b; + } + return dots; +} + +/* ---- src/web/lib/text.js ------------------------------------------------- */ + +/* + Text laid out the way `ptouch-print --text` does it. + + A port of render_text() and its helpers from ptouch-print.c, which cannot be + linked into a blob: that file is libgd and argp all the way down. The CLI + picks a font size that makes the tallest line fill its share of the tape, + builds an image exactly as wide as the widest line, and distributes the lines + down the print area. Everything below mirrors that, including the integer + arithmetic, so the same input yields the same layout. + + Verified against the real tool: for the cases in the test suite this picks + the same point size and produces the same label width as + `ptouch-print --force-tape-width N --text ... --write-png`. + + One deliberate difference: gd is called through gdImageStringFT_180dpi(), + which hardcodes 180 dpi, so the CLI renders text at half the intended + physical size on a 360 dpi model. Here the printer's real dpi is used. + + gd's brect maps onto canvas TextMetrics like this: + brect[1] descent below the baseline actualBoundingBoxDescent + brect[5] -ascent above the baseline -actualBoundingBoxAscent + brect[0] left edge of the ink -actualBoundingBoxLeft + brect[2] right edge of the ink actualBoundingBoxRight + so height = brect[1]-brect[5], needed_width = brect[2]-brect[0] and + offset_x = -brect[0] all carry over directly. +*/ + +export const MAX_LINES = 4; /* as in ptouch-print.c */ + +/* + The threshold is 50%, matching what the CLI does. ptouch-print.c renders with + a *negative* colour (`-black`), which tells libgd to disable anti-aliasing + and use FreeType's monochrome rasterizer - verified: the same string rendered + with `black` produces 9 distinct palette entries, with `-black` exactly 2. So + the CLI's text is natively 1-bit and its coverage rule is "is the pixel centre + inside the outline", which 50% approximates. + + The one thing that cannot be reproduced is FreeType's dropout control, which + deliberately keeps hairlines that would otherwise fall between pixel centres. + Lowering TEXT_COVERAGE thickens stems if that ever bites. + + What stops small text turning to mush is not the threshold but the size: the + text is scaled to the print area rather than to a fixed pixel size, so stems + are several dots wide. fillText()'s maxWidth argument is never used - it + condenses glyphs horizontally to fit, which destroys legibility far faster + than omitting the element does. +*/ +const TEXT_COVERAGE = 0.5; + +/* DejaVu Sans is the CLI's default font, so labels come out looking the same + either way. */ +const TEXT_FONT_STACK = '"DejaVu Sans", "Liberation Sans", Arial, Helvetica, sans-serif'; +const TEXT_WEIGHT = "normal"; + +/* Verbatim from find_fontsize(): measuring against a fixed set of ascenders and + descenders keeps every line the same height whatever it contains. */ +const COMMON_CHARS = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnoprqstuvwxyz{|}~"; + +let measureCtx = null; + +const textFont = (size) => `${TEXT_WEIGHT} ${size}px ${TEXT_FONT_STACK}`; + +/* Ink box rather than advance width, so text can be packed tightly. Already + rounded to integers, which is what FreeType reports, so the arithmetic below + stays integer as it is in C. */ +function ftMetrics(text, fontPx) { + if (!measureCtx) { + measureCtx = document.createElement("canvas").getContext("2d"); + } + measureCtx.font = textFont(fontPx); + const m = measureCtx.measureText(text); + const left = Math.ceil(m.actualBoundingBoxLeft ?? 0); + const right = Math.ceil(m.actualBoundingBoxRight ?? m.width); + const ascent = Math.ceil(m.actualBoundingBoxAscent ?? fontPx * 0.8); + const descent = Math.ceil(m.actualBoundingBoxDescent ?? fontPx * 0.2); + return { left, width: left + right, ascent, descent, height: ascent + descent }; +} + +const ptToPx = (pt, dpi) => pt * dpi / 72; + +/* find_fontsize(): the largest whole point size whose line height still fits. */ +function findFontSizePt(wantPx, text, dpi) { + const combined = text + COMMON_CHARS; + let save = 0; + for (let pt = 4; pt <= 500; ++pt) { + if (ftMetrics(combined, ptToPx(pt, dpi)).height <= wantPx) { + save = pt; + } else { + break; + } + } + return save === 0 ? -1 : save; +} + +/* get_baselineoffset(): how much further below the baseline this text reaches + than a letter that sits on it. 'z' is the CLI's reference glyph. */ +function baselineOffset(text, fontPx) { + return ftMetrics(text, fontPx).descent - ftMetrics("z", fontPx).descent; +} + +/* The CLI's --text splits on a literal \n as well as a real newline. */ +export function parseTextLines(input) { + return input.split(/\\n|\n/).slice(0, MAX_LINES); +} + +/* render_text(): the placement of every line, or a throw carrying the same + message the CLI prints. printWidth is the print area of the mounted tape. */ +export function layoutTextLabel(lines, printWidth, + { align = "l", fontSizePt = 0, fontMargin = 0, dpi }) { + let fsz = fontSizePt; + if (fsz <= 0) { + const wantPx = Math.floor((printWidth - 2 * fontMargin) / lines.length); + for (const line of lines) { + const candidate = findFontSizePt(wantPx, line, dpi); + if (candidate < 0) { + throw new Error("could not estimate needed font size"); + } + if (fsz === 0 || candidate < fsz) { + fsz = candidate; + } + } + } + + const fontPx = ptToPx(fsz, dpi); + const metrics = lines.map((line) => ftMetrics(line, fontPx)); + const width = Math.max(...metrics.map((m) => m.width)); + const maxHeight = Math.max(...metrics.map((m) => m.height)); + + if (maxHeight * lines.length > printWidth) { + throw new Error(`Font size ${fsz} too large for ${lines.length} lines`); + } + + const unusedPx = printWidth - maxHeight * lines.length; + const placements = lines.map((line, i) => { + const ofs = baselineOffset(line, fontPx); + let pos = i * Math.floor(printWidth / lines.length) + maxHeight - ofs; + pos += Math.floor(Math.floor(unusedPx / lines.length) / 2); + let alignOfs = 0; + if (align === "c") { + alignOfs = Math.floor((width - metrics[i].width) / 2); + } else if (align === "r") { + alignOfs = width - metrics[i].width; + } + return { text: line, x: metrics[i].left + alignOfs, baseline: pos }; + }); + + return { fontSizePt: fsz, fontPx, width, maxHeight, placements }; +} + +/* + Render the whole text block into one buffer and threshold it in a single + pass, the way the CLI fills one gd image. The result is already 1-bit, so + whoever composes the label can blit it 1:1 and keep it that way. +*/ +export function renderTextLabel(lines, printWidth, opts) { + const layout = layoutTextLabel(lines, printWidth, opts); + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, layout.width); + canvas.height = printWidth; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = textFont(layout.fontPx); + ctx.textBaseline = "alphabetic"; + ctx.fillStyle = "#000"; + for (const p of layout.placements) { + ctx.fillText(p.text, p.x, p.baseline); + } + + /* Opaque white background, so coverage is luminance rather than alpha. */ + const img = ctx.getImageData(0, 0, canvas.width, canvas.height); + const cutoff = Math.round((1 - TEXT_COVERAGE) * 255); + for (let i = 0; i < img.data.length; i += 4) { + const luminance = Math.round(0.299 * img.data[i] + 0.587 * img.data[i + 1] + + 0.114 * img.data[i + 2]); + const value = luminance <= cutoff ? 0 : 255; + img.data[i] = img.data[i + 1] = img.data[i + 2] = value; + img.data[i + 3] = 255; + } + ctx.putImageData(img, 0, 0); + return { canvas, layout }; +} + +/* ---- src/web/lib/pattern.js ---------------------------------------------- */ + +/* + A test pattern for a thermal printhead. + + Everything is laid out left to right and simply omitted when the tape runs + out. Nothing is ever scaled down to fit - a squeezed element is a misleading + test. +*/ + +/* + ctx the label canvas, one pixel per printhead pin + w, h the area to fill; h is the print area of the mounted tape + textBlock an already-thresholded canvas to blit at the left, or null +*/ +export function drawTestPattern(ctx, w, h, textBlock) { + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = "#000"; + ctx.strokeStyle = "#000"; + ctx.lineWidth = 1; + /* Crisp 1px strokes need half-pixel coordinates. */ + const line = (x1, y1, x2, y2) => { + ctx.beginPath(); + ctx.moveTo(x1 + 0.5, y1 + 0.5); + ctx.lineTo(x2 + 0.5, y2 + 0.5); + ctx.stroke(); + }; + + /* A frame on the outermost pins: if the print comes out clipped or off + centre, this is the thing that shows it. */ + ctx.strokeRect(0.5, 0.5, w - 1, h - 1); + + const gap = 6; + const margin = 5; + let x = margin; + const room = (need) => x + need <= w - margin; + + /* The text block comes first, rendered by the same --text layout used in + text-only mode, so the pattern shows real label text next to the geometry + rather than a second, differently-produced caption. Already 1-bit, so a + 1:1 blit is all that is needed. */ + if (textBlock && room(textBlock.width)) { + ctx.drawImage(textBlock, x, 0); + x += textBlock.width + gap; + } + + /* Vertical bars 1..4 px wide - horizontal (along-tape) resolution. */ + if (room(4 + 3 * 3 + 4)) { + for (let bar = 1; bar <= 4; ++bar) { + ctx.fillRect(x, 4, bar, h - 8); + x += bar + 3; + } + x += gap - 3; + } + + /* Horizontal rules at top, middle and bottom - vertical alignment. */ + const rules = 22; + if (room(rules)) { + line(x, 4, x + rules, 4); + line(x, (h >> 1), x + rules, (h >> 1)); + line(x, h - 5, x + rules, h - 5); + x += rules + gap; + } + + /* Solid block, then a hollow one. */ + const box = Math.max(6, Math.min(24, h - 16)); + const boxTop = (h - box) >> 1; + if (room(box)) { + ctx.fillRect(x, boxTop, box, box); + x += box + gap; + } + if (room(box)) { + ctx.strokeRect(x + 0.5, boxTop + 0.5, box - 1, box - 1); + x += box + gap; + } + + /* Circle - diagonal edges and anti-aliasing, which thresholding must cope + with. */ + const r = Math.max(3, Math.min(box, h - 12) / 2); + if (room(2 * r)) { + ctx.beginPath(); + ctx.arc(x + r, h / 2, r, 0, Math.PI * 2); + ctx.stroke(); + x += 2 * r + gap; + } + + /* An X. */ + const d = Math.min(24, h - 12); + const top = (h - d) >> 1; + if (room(d)) { + line(x, top, x + d, top + d); + line(x, top + d, x + d, top); + x += d + gap; + } + + /* 2px checkerboard. */ + const check = Math.min(24, h - 12); + if (room(check)) { + for (let cy = 0; cy < check; cy += 2) { + for (let cx = 0; cx < check; cx += 2) { + if (((cx + cy) / 2) % 2 === 0) { + ctx.fillRect(x + cx, top + cy, 2, 2); + } + } + } + x += check + gap; + } +} + +/* ---- src/web/lib/ruler.js ------------------------------------------------ */ + +/* + Millimetre rulers for the label preview. + + They live on their own canvases, deliberately not on the label canvas: that + one is the print bitmap, so anything drawn there would come out on the tape. + + One bitmap pixel is one printhead pin, so millimetres follow from the + printer's own dpi: 1 mm = dpi/25.4 pixels, which is 7.09 px at 180 dpi and + 14.17 px at 360 dpi. +*/ + +export const RULER_H = 20; /* height of the along-tape ruler, CSS px */ +export const RULER_W = 26; /* width of the across-tape ruler, CSS px */ + +/* Tick spacing in mm, coarsened as the preview shrinks: minor ticks need room + to stay distinguishable, numbered ticks need room not to collide. */ +function tickSteps(screenPxPerMm) { + const candidates = [[1, 5], [2, 10], [5, 25], [10, 50], [20, 100], [50, 250]]; + for (const [minor, major] of candidates) { + if (minor * screenPxPerMm >= 4 && major * screenPxPerMm >= 26) { + return { minor, major }; + } + } + return { minor: 100, major: 500 }; +} + +/* + canvas the ruler's own canvas + lengthPx the ruled span in bitmap pixels + axis "x" along the tape (below the label), "y" across it (beside it) + zoom screen pixels per bitmap pixel + dpi the printer's resolution, which is what makes a millimetre a + millimetre +*/ +export function drawRuler(canvas, { lengthPx, axis, zoom, dpi }) { + const dpr = window.devicePixelRatio || 1; + const pxPerMm = dpi / 25.4; + const span = lengthPx * zoom; /* CSS px along the ruled axis */ + const horizontal = axis === "x"; + const cssW = horizontal ? span : RULER_W; + const cssH = horizontal ? RULER_H : span; + + /* Backed at device resolution so the tick labels stay sharp; the label + canvas next to it stays 1:1 because it is pixel data, not a drawing. */ + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + + const ctx = canvas.getContext("2d"); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + const ink = getComputedStyle(document.body).color; + ctx.strokeStyle = ink; + ctx.fillStyle = ink; + ctx.globalAlpha = 0.65; + ctx.lineWidth = 1; + ctx.font = "9px system-ui, sans-serif"; + + /* The rule itself, along the edge shared with the preview. */ + ctx.beginPath(); + if (horizontal) { + ctx.moveTo(0, 0.5); + ctx.lineTo(span, 0.5); + } else { + ctx.moveTo(0.5, 0); + ctx.lineTo(0.5, span); + } + ctx.stroke(); + + const totalMm = lengthPx / pxPerMm; + const step = tickSteps(pxPerMm * zoom); + for (let mm = 0; mm <= totalMm; mm += step.minor) { + const at = Math.round(mm * pxPerMm * zoom) + 0.5; + const major = mm % step.major === 0; + const tick = major ? 8 : 4; + + ctx.globalAlpha = major ? 0.75 : 0.45; + ctx.beginPath(); + if (horizontal) { + ctx.moveTo(at, 0); + ctx.lineTo(at, tick); + } else { + ctx.moveTo(0, at); + ctx.lineTo(tick, at); + } + ctx.stroke(); + + if (!major) { + continue; + } + ctx.globalAlpha = 0.75; + const text = String(mm); + if (horizontal) { + /* Skip a number that would run past the end of the tape. */ + if (at + ctx.measureText(text).width + 2 > span) { + continue; + } + ctx.textBaseline = "top"; + ctx.fillText(text, at + 2, tick + 1); + } else { + if (at + 4 > span) { + continue; + } + ctx.textBaseline = "middle"; + ctx.fillText(text, tick + 2, at + 4); + } + } +}