Compare commits
No commits in common. "9803f23b8d996d06cfdf17ef18291be2a131f6b8" and "d32e718454d475fac9f43deaca203edacc3911c3" have entirely different histories.
9803f23b8d
...
d32e718454
11 changed files with 13 additions and 1636 deletions
1
deploy/prod/.gitignore
vendored
1
deploy/prod/.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
|||
.secrets/
|
||||
inventory.yml
|
||||
.frontend-build/
|
||||
|
|
|
|||
|
|
@ -41,13 +41,6 @@
|
|||
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.
|
||||
|
|
@ -255,14 +248,11 @@
|
|||
{% endif %}
|
||||
|
||||
tasks:
|
||||
- name: Install docker.io, nginx and rsync
|
||||
- name: Install docker.io and nginx
|
||||
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
|
||||
|
||||
|
|
@ -329,18 +319,12 @@
|
|||
# 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: "{{ ansible_user }}"
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0755"
|
||||
|
||||
- name: Create backend static output directory
|
||||
|
|
@ -446,56 +430,17 @@
|
|||
enabled: true
|
||||
state: started
|
||||
|
||||
# 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)
|
||||
- name: Build frontend builder docker image
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
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
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- 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)
|
||||
- name: Run frontend builder once to export the static build
|
||||
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
|
||||
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:
|
||||
|
|
|
|||
227
frontend/package-lock.json
generated
227
frontend/package-lock.json
generated
|
|
@ -569,11 +569,6 @@
|
|||
"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",
|
||||
|
|
@ -614,68 +609,11 @@
|
|||
"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"
|
||||
}
|
||||
|
|
@ -683,7 +621,8 @@
|
|||
"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=="
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"combined-stream": {
|
||||
"version": "1.0.8",
|
||||
|
|
@ -786,11 +725,6 @@
|
|||
"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",
|
||||
|
|
@ -812,11 +746,6 @@
|
|||
"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",
|
||||
|
|
@ -926,15 +855,6 @@
|
|||
"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",
|
||||
|
|
@ -963,11 +883,6 @@
|
|||
"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",
|
||||
|
|
@ -1077,7 +992,8 @@
|
|||
"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=="
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true
|
||||
},
|
||||
"is-glob": {
|
||||
"version": "4.0.3",
|
||||
|
|
@ -1188,14 +1104,6 @@
|
|||
"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",
|
||||
|
|
@ -1322,29 +1230,6 @@
|
|||
"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",
|
||||
|
|
@ -1360,11 +1245,6 @@
|
|||
"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",
|
||||
|
|
@ -1415,11 +1295,6 @@
|
|||
"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",
|
||||
|
|
@ -1478,16 +1353,6 @@
|
|||
"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",
|
||||
|
|
@ -1509,16 +1374,6 @@
|
|||
"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",
|
||||
|
|
@ -1572,11 +1427,6 @@
|
|||
"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",
|
||||
|
|
@ -1973,11 +1823,6 @@
|
|||
"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",
|
||||
|
|
@ -2071,68 +1916,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -51,12 +51,6 @@
|
|||
<span class="align-middle">Swatch</span>
|
||||
</router-link>
|
||||
</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>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import store from './store';
|
|||
|
||||
import _nacl from 'js-nacl';
|
||||
|
||||
|
||||
|
||||
const app = createApp(App).use(store).use(BootstrapIconsPlugin);
|
||||
|
||||
_nacl.instantiate((nacl) => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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';
|
||||
|
||||
|
|
@ -68,7 +67,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
}, {path: '/admin',
|
||||
component: Admin,
|
||||
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',
|
||||
component: Search,
|
||||
meta: {requiresAuth: true},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
2
frontend/vendor/libweblabel.mjs
vendored
2
frontend/vendor/libweblabel.mjs
vendored
File diff suppressed because one or more lines are too long
BIN
frontend/vendor/libweblabel.wasm
vendored
BIN
frontend/vendor/libweblabel.wasm
vendored
Binary file not shown.
958
frontend/vendor/weblabel.mjs
vendored
958
frontend/vendor/weblabel.mjs
vendored
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue