diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbd0963d..35ef7308 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,10 +31,37 @@ ADR-007 and ADR-002.
- **Contents can be positioned inside the label without moving the label.**
Align and Vertical place the code and caption block anywhere on the stock,
and Content X/Y nudge it from there. The existing X/Y nudge kept its
- behaviour and is now labelled Media X/Y: it moves the whole printed area, for
+ behavior and is now labeled Media X/Y: it moves the whole printed area, for
a printer whose origin is off, which is a different problem from composing a
label.
+- **A picture can go on the label.** Pick an image once and it prints on every
+ label - above the code, beside it, or behind everything as a watermark - on
+ the front, the back, or both. The file never leaves the browser, so an
+ air-gapped site can use it, and it is deliberately not saved between visits:
+ a data URL runs to megabytes and would take every sizing value with it when
+ the storage quota gave out.
+- **The back can carry its own code, in its own symbology.** "Back carries" now
+ defaults to a code rather than text, takes per-row content from the CSV
+ `back` column, and has its own QR/CODE128 choice - a QR on the front that
+ opens the record, a CODE128 on the back that the bench scanner already reads.
+ A back set to carry a code can no longer be laid out as text-only: that
+ combination printed a blank card that looked like the code had failed, and it
+ is corrected on load for anyone who already saved it.
+
+### Fixed
+
+- **The generator prints from inside the app shell.** It was a full-screen
+ route with no sidebar and a narrow column of controls on the left. It is now
+ an ordinary page - navigation, full width - and `style.css` hides the shell at
+ print time, so the stock still gets nothing but labels. Verified against a
+ real print PDF rather than a preview.
+- A test print paired each page with a code by loop position while the codes
+ are keyed to position in the whole run, so a two-sided test card could carry
+ the wrong code. The page index now travels with the page.
+- The back label's dotted preview outline outspecified the print reset and drew
+ a dashed rule down the edge of every second card.
+
### Changed
- CSV parsing and page building moved out of the generator component into
diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css
index 36e9d98c..5038c546 100644
--- a/frontend/src/assets/style.css
+++ b/frontend/src/assets/style.css
@@ -1763,3 +1763,31 @@ td.actions .btn + .btn {
independently. */
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }
+
+/* --- Printing from inside the app shell -------------------------------------
+ *
+ * Core's label pages are full-screen routes precisely so the shell cannot reach
+ * the stock. A tool someone works in for ten minutes wants the navigation
+ * though, so the shell is hidden here instead: the sidebar goes, the content
+ * column gives back the margin it was holding for it, and the notification
+ * banner - which is chrome, not content - goes with them.
+ *
+ * Anything the page itself must keep off the paper marks itself .no-print. */
+@media print {
+ .sidebar,
+ .notification-banner,
+ .no-print {
+ display: none !important;
+ }
+
+ .app-layout {
+ display: block !important;
+ min-height: 0 !important;
+ }
+
+ .main-content {
+ margin-left: 0 !important;
+ padding: 0 !important;
+ overflow: visible !important;
+ }
+}
diff --git a/frontend/src/components/SearchSuggestion.vue b/frontend/src/components/SearchSuggestion.vue
new file mode 100644
index 00000000..0e8c9e90
--- /dev/null
+++ b/frontend/src/components/SearchSuggestion.vue
@@ -0,0 +1,42 @@
+
+
+
+ Did you mean
+ ?
+
+
+
+
+
+
diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js
index cee61558..205dba43 100644
--- a/frontend/src/composables/listQuery.js
+++ b/frontend/src/composables/listQuery.js
@@ -1,5 +1,6 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
+import { useSearchSuggestion } from './searchSuggestion'
// Keep a list page's current page and search term in the URL query so the
// browser Back button restores them. Without this a list keeps page in local
@@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router'
// deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object.
+//
+// SPELLING SUGGESTIONS come along for free, because this composable already
+// owns the search term. A list reports how many rows it got and gets back a
+// suggestion to offer when that number is zero:
+//
+// const { search, suggestion, reportCount } = useListQuery({ onChange: load })
+// // at the end of load(): reportCount(rows.value.length)
+// // in the empty state:
+//
+// A list that never calls reportCount simply never offers one, which is what
+// every list did before.
export function useListQuery(options = {}) {
const route = useRoute()
const router = useRouter()
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
if (changed) onChange()
})
- return { page, search, extras, setPage, setSearch, setExtra }
+ const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
+
+ // Called by the list after a fetch. Zero rows for a non-empty search is the
+ // only moment a suggestion is worth asking for.
+ function reportCount(count) {
+ if (count > 0 || !search.value) clearSuggestion()
+ else checkSuggestion(search.value)
+ }
+
+ return {
+ page, search, extras, setPage, setSearch, setExtra,
+ suggestion, reportCount,
+ }
}
diff --git a/frontend/src/composables/searchSuggestion.js b/frontend/src/composables/searchSuggestion.js
new file mode 100644
index 00000000..9242c9a7
--- /dev/null
+++ b/frontend/src/composables/searchSuggestion.js
@@ -0,0 +1,56 @@
+import { ref } from 'vue'
+import api from '../api'
+
+/**
+ * "Did you mean" for a search that found nothing.
+ *
+ * ONE composable for every list in the product. Thirty routes take a search
+ * parameter across a dozen files, so carrying a suggestion in each of their
+ * responses would be a large change now and a thing every future plugin has to
+ * remember. Instead a page that renders no rows asks for a spelling, and a page
+ * that has not adopted this shows nothing - exactly what it showed before.
+ *
+ * Usage in a list view:
+ *
+ * const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
+ * // after a fetch:
+ * items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value)
+ *
+ * and in the template, beside the empty state:
+ *
+ *
+ */
+export function useSearchSuggestion() {
+ const suggestion = ref(null)
+ // Guards a slow answer arriving after a newer search has already been typed,
+ // which would offer a spelling for a query nobody is looking at any more.
+ let latest = 0
+
+ function clearSuggestion() {
+ suggestion.value = null
+ latest += 1
+ }
+
+ async function checkSuggestion(query) {
+ const term = (query || '').trim()
+ if (term.length < 2) {
+ clearSuggestion()
+ return
+ }
+ const ticket = ++latest
+ try {
+ const response = await api.get('/search/suggest', { params: { q: term } })
+ if (ticket !== latest) return
+ const offered = response.data?.data?.suggestion || null
+ // Never offer back what was typed: a suggestion identical to the query
+ // reads as a broken feature.
+ suggestion.value = offered && offered !== term.toLowerCase() ? offered : null
+ } catch (err) {
+ // A search that found nothing is already the answer. Failing to improve
+ // on it is not worth an error in front of anyone.
+ if (ticket === latest) suggestion.value = null
+ }
+ }
+
+ return { suggestion, checkSuggestion, clearSuggestion }
+}
diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue
index 48d35d5d..80d262f8 100644
--- a/frontend/src/views/SearchResults.vue
+++ b/frontend/src/views/SearchResults.vue
@@ -42,6 +42,7 @@
No results found for "{{ query }}"
+
@@ -94,12 +95,14 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
+import SearchSuggestion from '../components/SearchSuggestion.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
+const suggestion = ref(null)
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
async function search(q) {
if (!q || q.length < 2) {
results.value = []
+ suggestion.value = null
return
}
@@ -184,6 +188,10 @@ async function search(q) {
try {
const response = await searchApi.search(q)
const data = response.data.data
+ // The global endpoint carries the suggestion with the results, so no second
+ // call is needed here. A list page that has no such key uses the
+ // useSearchSuggestion composable against /search/suggest instead.
+ suggestion.value = data?.suggestion || null
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -212,6 +220,14 @@ async function search(q) {
}
}
+// Take the offered spelling: put it in the box and search it, so the person
+// sees what was searched rather than results for a word they did not type.
+function applySuggestion(word) {
+ searchInput.value = word
+ suggestion.value = null
+ performSearch()
+}
+
function performSearch() {
const term = searchInput.value.trim()
if (!term) return
diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue
index 734d5516..1b08da3e 100644
--- a/frontend/src/views/vendors/VendorsList.vue
+++ b/frontend/src/views/vendors/VendorsList.vue
@@ -55,6 +55,7 @@
No vendors found
+
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadVendors()
+}
const totalPages = ref(1)
const perPage = ref(20)
@@ -232,6 +243,7 @@ async function loadVendors() {
const response = await vendorsApi.list(params)
vendors.value = response.data.data || []
+ reportCount(vendors.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading vendors:', err)
diff --git a/plugins/computers/frontend/views/PCsList.vue b/plugins/computers/frontend/views/PCsList.vue
index d5d8567d..361c2c20 100644
--- a/plugins/computers/frontend/views/PCsList.vue
+++ b/plugins/computers/frontend/views/PCsList.vue
@@ -66,6 +66,7 @@
No computers found
+
@@ -91,10 +92,20 @@ import { computersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const computers = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadComputers()
+}
const totalPages = ref(1)
const perPage = ref(20)
@@ -115,6 +126,7 @@ async function loadComputers() {
const response = await computersApi.list(params)
computers.value = response.data.data || []
+ reportCount(computers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)
diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
index 01c048ca..dcbbfc6f 100644
--- a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
+++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
@@ -88,6 +88,7 @@
No articles found
+
@@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadArticles()
+}
const perPage = ref(20)
const totalPages = ref(1)
const topicFilter = ref('')
@@ -161,6 +172,7 @@ async function loadArticles() {
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
+ reportCount(articles.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)
diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue
index 19732aab..158d45a8 100644
--- a/plugins/machines/frontend/views/MachinesList.vue
+++ b/plugins/machines/frontend/views/MachinesList.vue
@@ -79,6 +79,7 @@
No machines found
+
@@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle"
import { machinesApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const machines = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines })
+
+// Take the offered spelling: put it in the box and search it, so what was
+// searched is what the person sees.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ // setSearch only syncs the URL, and the watcher that reloads is suppressed
+ // because search.value already holds the new term - the same trap the global
+ // search page documents. Load directly.
+ loadMachines()
+}
const totalPages = ref(1)
const perPage = ref(20)
@@ -129,6 +142,7 @@ async function loadMachines() {
const response = await machinesApi.list(params)
machines.value = response.data.data || []
+ reportCount(machines.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading machines:', error)
diff --git a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue
index f0ea431b..856c8fc1 100644
--- a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue
+++ b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue
@@ -73,6 +73,7 @@
No measuring tools found
+
@@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const tools = ref([])
const types = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadTools()
+}
const typeid = ref('')
const calibrationstatus = ref('')
const totalPages = ref(1)
@@ -133,6 +144,7 @@ async function loadTools() {
const response = await measuringtoolsApi.list(params)
tools.value = response.data.data || []
+ reportCount(tools.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading measuring tools:', err)
diff --git a/plugins/network/frontend/views/NetworkDevicesList.vue b/plugins/network/frontend/views/NetworkDevicesList.vue
index 8195a9af..e6e5719f 100644
--- a/plugins/network/frontend/views/NetworkDevicesList.vue
+++ b/plugins/network/frontend/views/NetworkDevicesList.vue
@@ -97,6 +97,7 @@
No network devices found
+
@@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices })
+
+// Take the offered spelling. setSearch only syncs the URL and the watcher
+// that reloads is suppressed because search.value already holds the new
+// term, so load directly.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ loadDevices()
+}
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
@@ -220,6 +231,7 @@ async function loadDevices() {
const response = await networkApi.list(params)
devices.value = response.data.data || []
+ reportCount(devices.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)
diff --git a/plugins/printers/frontend/views/PrintersList.vue b/plugins/printers/frontend/views/PrintersList.vue
index 36cacdf7..adc92675 100644
--- a/plugins/printers/frontend/views/PrintersList.vue
+++ b/plugins/printers/frontend/views/PrintersList.vue
@@ -68,6 +68,7 @@
No printers found
+
@@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
const loading = ref(true)
-const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters })
+const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters })
+
+// Take the offered spelling: put it in the box and search it, so what was
+// searched is what the person sees.
+function applySuggestion(word) {
+ search.value = word
+ setSearch(word)
+ // setSearch only syncs the URL, and the watcher that reloads is suppressed
+ // because search.value already holds the new term - the same trap the global
+ // search page documents. Load directly.
+ loadPrinters()
+}
const totalPages = ref(1)
const perPage = ref(20)
@@ -125,6 +138,7 @@ async function loadPrinters() {
const response = await printersApi.list(params)
printers.value = response.data.data || []
+ reportCount(printers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading printers:', error)
diff --git a/plugins/tools/frontend/routes.js b/plugins/tools/frontend/routes.js
index aa152858..e17ac47e 100644
--- a/plugins/tools/frontend/routes.js
+++ b/plugins/tools/frontend/routes.js
@@ -3,9 +3,12 @@
*
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
*
- * The index sits inside the app shell. Each tool that PRINTS is toplevel, the
- * way every other label page in this repo is: printing from inside AppLayout
- * would put the sidebar and header on the label stock.
+ * The generator sits INSIDE the app shell, unlike the label pages in core.
+ * Those are toplevel because a print from inside AppLayout would put the
+ * sidebar on the label stock - which is true unless the print stylesheet says
+ * otherwise, and style.css now does: the shell is hidden at print time so only
+ * the labels reach the page. A tool someone spends ten minutes in wants the
+ * navigation; a one-shot print page does not.
*/
export default [
{
@@ -14,11 +17,8 @@ export default [
component: () => import('./views/ToolsIndex.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
},
-]
-
-export const toplevel = [
{
- path: '/tools/codes',
+ path: 'tools/codes',
name: 'tools-codes',
component: () => import('./views/CodeGenerator.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue
index 9b1cac6c..78fb6a60 100644
--- a/plugins/tools/frontend/views/CodeGenerator.vue
+++ b/plugins/tools/frontend/views/CodeGenerator.vue
@@ -1,12 +1,12 @@
-
+
-
-
-
Barcode / QR Generator
- Back to Tech Tools
-
+
+
Barcode / QR Generator
+ Back to Tech Tools
+
+
@@ -135,7 +135,7 @@
Align
@@ -159,6 +159,47 @@
is for a printer whose origin is off, not for composing a label.
+
+
Picture
+
+
+
+
+
+
+
+
+
+
+
+
+ The picture never leaves the browser, so this works on an air-gapped
+ site. It is also NOT remembered between visits, unlike the sizing -
+ an image is far too big to keep in the settings the page saves.
+ A watermark behind a code costs the scanner contrast: keep the opacity
+ low and test a scan before printing a run.
+
+
Back side
@@ -170,19 +211,27 @@
+
-
@@ -241,20 +291,27 @@
+
-
-
+
+
-
- {{ page.label }}
+
+
+
+
+
+ {{ item.page.label }}
+
- {{ page.side }}
+ {{ item.page.side }}
@@ -298,10 +355,20 @@ const nudgex = ref(0)
const nudgey = ref(0)
const dpi = ref(203)
+// The image itself is deliberately NOT a saved setting: a data URL runs to
+// megabytes and would blow the localStorage quota, taking every sizing value
+// with it. Only how it is placed persists.
+const pictureUrl = ref('')
+const picturesize = ref(0.5)
+const pictureposition = ref('above')
+const pictureside = ref('front')
+const pictureopacity = ref(0.15)
+
const backenabled = ref(false)
-const backsource = ref('text')
+const backsource = ref('code')
+const backcodetype = ref('same')
const backtext = ref('')
-const backlayout = ref('textonly')
+const backlayout = ref('stack')
const backorder = ref('interleave')
const images = ref([])
@@ -312,13 +379,61 @@ const SETTING_KEYS = [
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi',
- 'backenabled', 'backsource', 'backtext', 'backlayout', 'backorder',
+ 'picturesize', 'pictureposition', 'pictureside', 'pictureopacity',
+ 'backenabled', 'backsource', 'backcodetype', 'backtext', 'backlayout', 'backorder',
]
const settingRefs = {
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
quiet, gap, codesize, barheight, labelfont, layout, align, valign,
contentx, contenty, nudgex, nudgey, dpi,
- backenabled, backsource, backtext, backlayout, backorder,
+ picturesize, pictureposition, pictureside, pictureopacity,
+ backenabled, backsource, backcodetype, backtext, backlayout, backorder,
+}
+
+// --- back side --------------------------------------------------------------
+
+const backCarriesCode = computed(() =>
+ backsource.value === 'code' || backsource.value === 'column')
+
+const backCarriesOwnContent = computed(() =>
+ backenabled.value && backsource.value === 'column')
+
+// A back that carries a code and a back laid out as text-only cancel each other
+// out, and the result is a blank card that looks like the code failed to
+// render. Keep the two honest in both directions, including for settings
+// restored from a previous visit.
+function reconcileBackLayout() {
+ if (backCarriesCode.value && backlayout.value === 'textonly') backlayout.value = 'stack'
+ if (!backCarriesCode.value && backlayout.value !== 'textonly') backlayout.value = 'textonly'
+}
+
+watch(backsource, reconcileBackLayout)
+
+/** QR or CODE128 for this page. The back may deliberately differ from the
+ * front: a QR that opens a record, a CODE128 the bench scanner already
+ * understands. */
+function codeTypeFor(page) {
+ if (page.side !== 'back') return codetype.value
+ return backcodetype.value === 'same' ? codetype.value : backcodetype.value
+}
+
+function layoutFor(page) {
+ return page.side === 'back' ? backlayout.value : layout.value
+}
+
+function showPicture(page) {
+ if (!pictureUrl.value) return false
+ if (pictureside.value === 'both') return true
+ return pictureside.value === page.side
+}
+
+function labelClasses(page) {
+ return [
+ 'layout-' + layoutFor(page),
+ 'type-' + codeTypeFor(page),
+ 'side-' + page.side,
+ showPicture(page) && pictureposition.value !== 'behind' ? 'pic-' + pictureposition.value : '',
+ ]
}
// --- rows -------------------------------------------------------------------
@@ -366,19 +481,26 @@ const pages = computed(() => built.value.pages)
const cardCount = computed(() => built.value.fronts.length)
const truncated = computed(() => built.value.dropped)
-// Printing one test label prints the first card only - both of its sides when
-// there is a back, because a back that lands upside down is the whole reason
-// to print one first.
-const visiblePages = computed(() => {
- if (!testMode.value) return pages.value
+// What the preview renders, each item carrying its index into `images`. The
+// index has to travel with the page: a test print shows two of the pages and
+// the rendered codes are keyed by position in the full run, so pairing them by
+// loop position printed the wrong code onto the test card.
+const visible = computed(() => {
+ const all = pages.value.map((page, index) => ({ page, index }))
+ if (!testMode.value) return all
const { fronts, backs } = built.value
- return [fronts[0], backenabled.value ? backs[0] : null].filter(Boolean)
+ if (!fronts.length) return []
+ const items = [{ page: fronts[0], index: 0 }]
+ if (backenabled.value && backs.length) {
+ // Where the first back sits depends on the order the run is emitted in.
+ items.push({
+ page: backs[0],
+ index: backorder.value === 'grouped' ? fronts.length : 1,
+ })
+ }
+ return items
})
-function layoutFor(page) {
- return page.side === 'back' ? backlayout.value : layout.value
-}
-
// --- fit advice -------------------------------------------------------------
// Modules across the QR at the current content and error correction, quiet
@@ -416,10 +538,10 @@ const fitAdvice = computed(() => {
//
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
// so none of the code box is spent on white we cannot then adjust.
-async function renderOne(row) {
- const text = row.content
+async function renderOne(page) {
+ const text = page.content
if (!text) return ''
- if (codetype.value === 'qr') {
+ if (codeTypeFor(page) === 'qr') {
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
}
return barcodeSvgDataUri(text)
@@ -453,6 +575,7 @@ const VAR_NAMES = [
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
+ '--tool-pic', '--tool-pic-opacity',
]
function applyVars() {
@@ -478,6 +601,8 @@ function applyVars() {
root.style.setProperty('--tool-content-y', contenty.value + 'in')
root.style.setProperty('--tool-nudge-x', nudgex.value + 'in')
root.style.setProperty('--tool-nudge-y', nudgey.value + 'in')
+ root.style.setProperty('--tool-pic', picturesize.value + 'in')
+ root.style.setProperty('--tool-pic-opacity', String(pictureopacity.value))
}
function clearVars() {
@@ -510,6 +635,21 @@ function resetSettings() {
nudgey.value = 0
}
+// --- picture ----------------------------------------------------------------
+
+function onPictureFile(event) {
+ const file = event.target.files && event.target.files[0]
+ if (!file) return
+ const reader = new FileReader()
+ reader.onload = () => { pictureUrl.value = String(reader.result || '') }
+ reader.onerror = () => { pictureUrl.value = '' }
+ reader.readAsDataURL(file)
+}
+
+function clearPicture() {
+ pictureUrl.value = ''
+}
+
// --- csv helpers ------------------------------------------------------------
function onCsvFile(event) {
@@ -524,8 +664,7 @@ function onCsvFile(event) {
function downloadTemplate() {
// The template carries the back columns only when they would be read, so a
// one-sided run is not handed a CSV with two columns it must ignore.
- const perRowBack = backenabled.value && backsource.value === 'column'
- const csv = perRowBack
+ const csv = backCarriesOwnContent.value
? [
'content,label,copies,back,backlabel',
'https://example.com/asset/1,Bay 12 press,1,https://example.com/asset/1/history,Service history',
@@ -571,6 +710,7 @@ onMounted(() => {
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
}
} catch { /* ignore a corrupt entry */ }
+ reconcileBackLayout()
applyVars()
renderAll()
})
@@ -582,7 +722,7 @@ watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
saveSettings()
})
-watch([pages, codetype, errorcorrection], renderAll)
+watch([pages, codetype, backcodetype, errorcorrection], renderAll)