Files
wanderbooks/crates/ledger-import/templates/labels.typ
T
swaitsandClaude Opus 5.5 ee7e205f5c
check / check (push) Waiting to run
Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify
- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:18:00 -06:00

92 lines
3.7 KiB
Typst

// Asset label sheets: one tag per label, with its QR code.
//
// Everything arrives as data in sys.inputs.data (JSON), built by
// ledger-import::labels: the sheet geometry (in points, from
// docs/spec/sources/avery-5160.md), and one entry per sheet position. The
// template only typesets: no label text is ever parsed as markup, and every
// label is drawn inside a clipped box the size of its cell, so nothing can
// print past its edge.
//
// Shape: { company, geometry: { page_width, page_height, label_width,
// label_height, left, top, pitch_x, pitch_y, columns, rows, inset },
// pages: [[null | { tag, description, serial: null | str, qr: svg }]] }
// Each inner list is one sheet, positions in reading order (left to right,
// then top to bottom); null is a position left blank.
#let d = json(bytes(sys.inputs.data))
#let g = d.geometry
#let pt1 = 1pt
#let ink = luma(15)
#let quiet = luma(90)
#set document(title: d.company + " — Asset labels", author: d.company)
#set text(font: "Inter", fill: ink, number-width: "tabular", hyphenate: false)
#set par(leading: 0.28em, spacing: 0pt)
#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt)
// The inner box every label's content lives in.
#let inset = g.inset * pt1
#let cell-w = g.label_width * pt1
#let cell-h = g.label_height * pt1
// The QR code (its 4-module quiet zone included) fills the label's height.
#let qr-size = cell-h - 2 * inset
#let gap = 4pt
#let text-w = cell-w - 2 * inset - qr-size - gap
// The largest of `sizes` at which `s` fits in `lines` lines of `width`;
// at the smallest size, the longest prefix that fits, ending in "…".
// Text is set as a string, so it is always literal.
#let fit(s, width, sizes, lines, weight: "regular", fill: ink) = context {
let set-at(size, body) = block(width: width, text(size: size, weight: weight, fill: fill, body))
let limit(size) = measure(set-at(size, ("X\n" * lines).trim())).height + 0.01pt
// Fits: wraps into `lines` lines, and no word is wider than the box
// (an unbreakable word would overflow sideways, not down).
let fits(size, body) = (
measure(set-at(size, body)).height <= limit(size)
and body.split(" ").all(w => measure(text(size: size, weight: weight, w)).width <= width)
)
let chosen = sizes.find(size => fits(size, s))
if chosen != none {
set-at(chosen, s)
} else {
let size = sizes.last()
let cs = s.clusters()
// Binary search for the longest prefix that fits with the ellipsis.
let lo = 0
let hi = cs.len()
while lo < hi {
let mid = calc.quo(lo + hi + 1, 2)
if fits(size, cs.slice(0, mid).join().trim() + "…") { lo = mid } else { hi = mid - 1 }
}
let prefix = if lo == 0 { "" } else { cs.slice(0, lo).join().trim() }
set-at(size, prefix + "…")
}
}
#let label(l) = box(width: cell-w, height: cell-h, clip: true, inset: inset, {
grid(
columns: (qr-size, gap, text-w),
rows: (qr-size,),
image(bytes(l.qr), format: "svg", width: qr-size, height: qr-size),
[],
align(horizon, stack(
spacing: 2.2pt,
fit(d.company, text-w, (6pt, 5.5pt, 5pt), 1, fill: quiet),
fit(l.tag, text-w, (15pt, 13pt, 11pt, 9pt, 8pt, 7pt, 6pt), 1, weight: "bold"),
fit(l.description, text-w, (7.5pt, 7pt, 6.5pt, 6pt), 2),
if l.serial != none { fit("S/N " + l.serial, text-w, (6.5pt, 6pt, 5.5pt, 5pt), 1, fill: quiet) },
)),
)
})
#for (i, sheet) in d.pages.enumerate() {
if i > 0 { pagebreak() }
for (p, l) in sheet.enumerate() {
if l != none {
let col = calc.rem(p, g.columns)
let row = calc.quo(p, g.columns)
place(top + left, dx: (g.left + col * g.pitch_x) * pt1, dy: (g.top + row * g.pitch_y) * pt1, label(l))
}
}
}