Compare commits

..

No commits in common. "9803f23b8d996d06cfdf17ef18291be2a131f6b8" and "d32e718454d475fac9f43deaca203edacc3911c3" have entirely different histories.

11 changed files with 13 additions and 1636 deletions

View file

@ -1,3 +1,2 @@
.secrets/ .secrets/
inventory.yml inventory.yml
.frontend-build/

View file

@ -41,13 +41,6 @@
toolshed_backend_port: 8000 toolshed_backend_port: 8000
toolshed_wiki_dist_dir: /var/www/toolshed-wiki toolshed_wiki_dist_dir: /var/www/toolshed-wiki
toolshed_local_dir: /var/www/toolshed-local 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 # Django's collectstatic output (admin/drf-yasg assets etc.), exported
# from the built backend image so nginx can serve it directly instead of # from the built backend image so nginx can serve it directly instead of
# proxying to gunicorn for every asset request. # proxying to gunicorn for every asset request.
@ -255,14 +248,11 @@
{% endif %} {% endif %}
tasks: tasks:
- name: Install docker.io, nginx and rsync - name: Install docker.io and nginx
ansible.builtin.apt: ansible.builtin.apt:
name: name:
- docker.io - docker.io
- nginx - nginx
# rsync is what the frontend dist sync (further down) relies on -
# it's the ansible.posix.synchronize module's transport.
- rsync
state: present state: present
update_cache: true update_cache: true
@ -329,18 +319,12 @@
# until reloaded. # until reloaded.
notify: reload nginx 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 - name: Create frontend static output directory
ansible.builtin.file: ansible.builtin.file:
path: "{{ toolshed_dist_dir }}" path: "{{ toolshed_dist_dir }}"
state: directory state: directory
owner: "{{ ansible_user }}" owner: www-data
group: www-data
mode: "0755" mode: "0755"
- name: Create backend static output directory - name: Create backend static output directory
@ -446,56 +430,17 @@
enabled: true enabled: true
state: started state: started
# The next few tasks build the frontend on the controller instead of the - name: Build frontend builder docker image
# 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: ansible.builtin.command:
cmd: >- cmd: >-
docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }}
-f {{ toolshed_frontend_build_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_frontend_build_src_dir }}/frontend -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_src_dir }}/frontend
changed_when: true changed_when: true
delegate_to: localhost
become: false
- name: Create local frontend dist scratch directory (controller) - name: Run frontend builder once to export the static build
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: ansible.builtin.command:
cmd: docker run --rm -v {{ toolshed_frontend_build_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} cmd: docker run --rm -v {{ toolshed_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }}
changed_when: true 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 - name: Fix ownership of exported frontend build
ansible.builtin.file: ansible.builtin.file:

View file

@ -569,11 +569,6 @@
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true "dev": true
}, },
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
},
"chai": { "chai": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
@ -614,68 +609,11 @@
"readdirp": "~3.6.0" "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": { "color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": { "requires": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
} }
@ -683,7 +621,8 @@
"color-name": { "color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
}, },
"combined-stream": { "combined-stream": {
"version": "1.0.8", "version": "1.0.8",
@ -786,11 +725,6 @@
"ms": "^2.1.3" "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": { "decimal.js": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@ -812,11 +746,6 @@
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true "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": { "dns-query": {
"version": "0.11.2", "version": "0.11.2",
"resolved": "https://registry.npmjs.org/dns-query/-/dns-query-0.11.2.tgz", "resolved": "https://registry.npmjs.org/dns-query/-/dns-query-0.11.2.tgz",
@ -926,15 +855,6 @@
"to-regex-range": "^5.0.1" "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": { "foreground-child": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@ -963,11 +883,6 @@
"dev": true, "dev": true,
"optional": 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": { "get-func-name": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@ -1077,7 +992,8 @@
"is-fullwidth-code-point": { "is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true
}, },
"is-glob": { "is-glob": {
"version": "4.0.3", "version": "4.0.3",
@ -1188,14 +1104,6 @@
"integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==",
"dev": true "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": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@ -1322,29 +1230,6 @@
"yocto-queue": "^1.0.0" "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": { "package-json-from-dist": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
@ -1360,11 +1245,6 @@
"entities": "^4.4.0" "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": { "path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -1415,11 +1295,6 @@
"pathe": "^1.1.2" "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": { "popper.js": {
"version": "1.16.1", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
@ -1478,16 +1353,6 @@
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true "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": { "querystringify": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@ -1509,16 +1374,6 @@
"picomatch": "^2.2.1" "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": { "requires-port": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@ -1572,11 +1427,6 @@
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true "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": { "shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -1973,11 +1823,6 @@
"isexe": "^2.0.0" "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": { "why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -2071,68 +1916,6 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true "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": { "yocto-queue": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",

View file

@ -13,7 +13,6 @@
"dns-query": "^0.11.2", "dns-query": "^0.11.2",
"js-nacl": "^1.4.0", "js-nacl": "^1.4.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"qrcode": "^1.5.4",
"vue": "^3.2.47", "vue": "^3.2.47",
"vue-multiselect": "^2.1.7", "vue-multiselect": "^2.1.7",
"vue-router": "^4.1.6", "vue-router": "^4.1.6",

View file

@ -51,12 +51,6 @@
<span class="align-middle">Swatch</span> <span class="align-middle">Swatch</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item">
<router-link to="/print" class="sidebar-link">
<b-icon-gear class="bi-valign-middle"></b-icon-gear>
<span class="align-middle">Print</span>
</router-link>
</li>
</ul> </ul>
</div> </div>
</nav> </nav>

View file

@ -10,8 +10,6 @@ import store from './store';
import _nacl from 'js-nacl'; import _nacl from 'js-nacl';
const app = createApp(App).use(store).use(BootstrapIconsPlugin); const app = createApp(App).use(store).use(BootstrapIconsPlugin);
_nacl.instantiate((nacl) => { _nacl.instantiate((nacl) => {

View file

@ -20,7 +20,6 @@ import StorageLocationEdit from '@/views/StorageLocationEdit.vue';
import Admin from '@/views/Admin.vue'; import Admin from '@/views/Admin.vue';
import Swatch from '@/views/Swatch.vue'; import Swatch from '@/views/Swatch.vue';
import Files from '@/views/Files.vue'; import Files from '@/views/Files.vue';
import Print from "@/views/Print.vue";
import Workflows from '@/views/Workflows.vue'; import Workflows from '@/views/Workflows.vue';
import WorkflowDetail from '@/views/WorkflowDetail.vue'; import WorkflowDetail from '@/views/WorkflowDetail.vue';
@ -68,7 +67,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}, {path: '/admin', }, {path: '/admin',
component: Admin, component: Admin,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {path: '/print', component: Print, meta: {requiresAuth: true}}, { }, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
path: '/search/:query', path: '/search/:query',
component: Search, component: Search,
meta: {requiresAuth: true}, meta: {requiresAuth: true},

View file

@ -1,380 +0,0 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Print a label</h1>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!usbSupported" class="alert alert-warning">
This browser can't talk to USB label printers. Open this page in Chrome, Edge or Opera
over https:// (or http://localhost).
</div>
<div v-else class="row">
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Label</h5>
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape</small>
</div>
<div class="card-body">
<p v-if="!tape" class="text-muted">
Connect a printer to preview and print a label.
</p>
<template v-else>
<div class="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="value">
<canvas ref="labelCanvas"></canvas>
</div>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Copies</label>
<input type="number" class="form-control copies-input"
v-model.number="copies" min="1" max="20">
</div>
<div class="col-auto ms-auto">
<button class="btn btn-primary" :disabled="!canPrint || busy"
@click="print">
<b-icon-printer class="me-1"></b-icon-printer>
Print
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="col-lg-5 mb-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Printer</h5>
</div>
<div class="card-body">
<p v-if="!devices.length" class="text-muted">
No label printer paired yet.
</p>
<ul v-else class="list-group mb-3">
<li v-for="d in deviceRows" :key="d.index"
class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div>{{ d.name }}</div>
<small v-if="d.isConnected" class="text-success">connected</small>
<small v-else-if="!d.supported" class="text-muted">{{ d.reason }}</small>
</div>
<button v-if="d.isConnected" class="btn btn-sm btn-outline-secondary"
:disabled="busy" @click="disconnect()">
Disconnect
</button>
<button v-else-if="d.supported" class="btn btn-sm btn-outline-primary"
:disabled="busy || connected !== null" @click="connect(d.index)">
Connect
</button>
</li>
</ul>
<button class="btn btn-primary" :disabled="busy || !blobReady" @click="requestPrinter">
<b-icon-plug class="me-1"></b-icon-plug>
Pair new printer
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import QRCode from "qrcode";
import BaseLayout from "@/components/BaseLayout.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.mjs";
const BLOB_URL = new URL("../../vendor/libweblabel.mjs", import.meta.url).href;
const MAX_ZOOM = 4; /* never magnify the preview more than this */
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
feed margin already keeps the code clear of the tape edge and the cut. */
const QUIET_ZONE_MODULES = 2;
/* Sizing a label needs numbers only the driver can supply. */
function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
Draw the QR code as large as the tape allows, centered in a square, with an
integer number of pixels per module so it stays crisp at printer
resolution rather than blurring at a fractional scale.
*/
function drawQrLabel(canvas, qr, tape) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
const square = modules * scale;
const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
const top = Math.floor((canvas.height - square) / 2);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
left + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
}
export default {
name: "Print",
components: {
BaseLayout,
...BIcons
},
data() {
return {
usbSupported: true,
blobReady: false,
busy: false,
error: null,
devices: [],
connected: null,
value: "",
copies: 1,
};
},
computed: {
deviceRows() {
if (!this.blob) {
return [];
}
return this.devices.map((d, index) => {
const info = this.blob.findDevice(d.vendorId, d.productId);
return {
index,
name: info ? info.name : (d.productName || "Unknown device"),
supported: Boolean(info && info.supported),
reason: info ? (info.unsupportedReason || "unsupported") : "no driver for this device",
isConnected: d === this.connected,
};
});
},
canPrint() {
return Boolean(this.value && this.tape && this.labelBitmap && !this.busy);
},
},
watch: {
value() {
this.redraw();
},
},
methods: {
async guard(fn) {
this.error = null;
this.busy = true;
try {
await fn();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async refreshDevices() {
this.devices = (await navigator.usb.getDevices()).map((d) => markRaw(d));
/* A printer unplugged while open is off the list: the handle it was
opened through is gone, so drop it rather than keep the label
card open on a connection that no longer exists. */
if (this.connected !== null && !this.devices.includes(this.connected)) {
this.connected = null;
this.tape = null;
}
},
requestPrinter() {
this.guard(async () => {
try {
await navigator.usb.requestDevice({filters: this.blob.usbFilters()});
} catch (e) {
if (e.name === "NotFoundError") {
return;
}
throw e;
}
await this.refreshDevices();
});
},
connect(index) {
this.guard(async () => {
const device = this.devices[index];
this.blob.setDevices([device]);
await this.blob.open(device.vendorId, device.productId);
this.connected = device;
const status = await this.blob.status();
this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
await nextTick();
this.redraw();
});
},
disconnect() {
this.guard(async () => {
await this.blob.close();
this.connected = null;
this.tape = null;
});
},
redraw() {
this.labelBitmap = null;
if (!this.tape || !this.value) {
return;
}
const canvas = this.$refs.labelCanvas;
if (!canvas) {
return;
}
try {
const qr = QRCode.create(this.value);
drawQrLabel(canvas, qr, this.tape);
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
const bitmap = canvasToBitmap(canvas);
bitmapToCanvas(canvas, bitmap);
this.labelBitmap = bitmap;
this.fitZoom(canvas);
},
/* Fit the preview to its card without ever needing a horizontal
scrollbar for a label this small, magnifying short labels up to
MAX_ZOOM rather than showing them at native (tiny) size. */
fitZoom(canvas) {
const available = canvas.parentElement.clientWidth;
if (!(available > 0)) {
return;
}
const zoom = Math.min(MAX_ZOOM, available / canvas.width);
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
},
print() {
this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
await this.blob.printBitmap(this.labelBitmap, {copies});
});
},
handleResize() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.redraw(), 100);
},
onUsbChange() {
this.guard(() => this.refreshDevices());
},
},
created() {
this.blob = null;
this.tape = null;
this.labelBitmap = null;
this.resizeTimer = null;
},
async mounted() {
if (!("usb" in navigator)) {
this.usbSupported = false;
return;
}
try {
this.blob = await MultiPrinterBlob.load(BLOB_URL);
} catch (e) {
this.error = `Could not load the printer driver: ${e.message}`;
return;
}
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
window.addEventListener("resize", this.handleResize);
await this.guard(() => this.refreshDevices());
},
beforeUnmount() {
if ("usb" in navigator) {
navigator.usb.removeEventListener("connect", this.onUsbChange);
navigator.usb.removeEventListener("disconnect", this.onUsbChange);
}
window.removeEventListener("resize", this.handleResize);
clearTimeout(this.resizeTimer);
},
}
</script>
<style scoped>
.label-preview {
overflow-x: auto;
padding: .75rem;
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
text-align: center;
}
.label-preview canvas {
display: inline-block;
background: #fff;
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
}
.copies-input {
width: 5.5rem;
}
</style>

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1,958 +0,0 @@
/*
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 <n-m:1.0> | 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_<driver>_<name>. */
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);
}
}
}