diff --git a/README.md b/README.md
index 2dd6fc9..4cb6129 100644
--- a/README.md
+++ b/README.md
@@ -182,7 +182,8 @@ The Pi renders, so **clients never install a driver** — they just add the shar
| **Print Mode** (halftone) | **Default** (threshold — crisp text/barcodes) · **Gathering** (dither — greys/photos) · None · Diffusion · Error Diffusion | — (rendered into the bitmap) |
| **Darkness** | `0`–`15` (default 8) | `DENSITY` |
| **Print Speed** | `1`–`6` in/sec (default 4) · Printer default (sends nothing) | `SPEED` |
-| **Media tracking** | **Die-cut (gap)** · Black-mark · Continuous · Printer setting | `GAP` / `BLINE` |
+| **Media tracking** | **Die-cut (gap)** · Black-mark · Continuous · Fixed pitch (die-cut, sensor off) · Printer setting | `GAP` / `BLINE` |
+| **Gap / black-mark length** | `1.5`–`5` mm (default 3 mm) | the length in `GAP`/`BLINE`; on Fixed pitch, added to `SIZE` |
| **Resolution** | `203` / `300` dpi | — |
Loaded **black-mark or continuous stock** instead of die-cut labels? Set it per queue —
@@ -193,6 +194,25 @@ TSPL firmwares skip or garble labels when `SIZE` disagrees with the stock. Note
sends no boundary command at all — and GAP/BLINE **persist in printer memory**, so such a queue
inherits whatever the last job set (e.g. `GAP 0` from a Continuous queue sharing the printer).
+**Gap length.** The `3 mm` in `GAP 3 mm,0 mm` is the TSC factory default and what every other TSPL
+driver sends, but small die-cut labels are often cut with a **2 mm** gap, the minimum TSC rates its
+sensors for. If labels lose register, measure the gap and set it: `-o GapLength=20` (tenths of a
+millimetre; a bare `-o GapLength=2` or `-o GapLength=2.5` is read as millimetres). Values under 1 mm
+are refused on the sensor modes: TSPL takes `GAP 0` as "continuous", and the printer remembers it.
+
+**Fixed pitch.** Some clone sensors cannot hold a very short pitch (a 12 mm label with a 2 mm gap, say):
+the first label prints, the next feed faults, whatever `GAP` says. `-o MediaTracking=FixedPitch`
+takes the sensor out of the loop: it sends `GAP 0` like Continuous but puts the full **label + gap**
+pitch in `SIZE` (from `GapLength`), so the printer feeds blind and stays in register. Plain
+**Continuous** is untouched: on a continuous roll the page height *is* the feed length.
+
+Two things come with running blind. The **last label stops short of the tear bar** — positioning to
+tear-off needs the gap sensor, so it sits half out until the next job pushes it through; that is the
+printer, not a lost job. And the pitch is only as good as your measurement: `GapLength` has to match
+the real gap or the image walks a little further along the roll with every label. Measure it, print a
+few, and check the last one still lands where the first did. Set the queue back to **Die-cut (gap)**
+and the printer re-syncs on the next job.
+
Two queues: crisp labels + a "photo" (Gathering) queue
@@ -211,7 +231,10 @@ sudo lpadmin -p HZD950-Photo -E -v tspl://auto -P /usr/share/ppd/tspl/tspl-label
Baked into the queue default, this works even for driverless clients (AirPrint/IPP-Everywhere) that can't
show the option menus — they just pick the right queue. Values: **PrintMode** `5`=Default `3`=Gathering
`0`=None `2`=Diffusion `4`=ErrorDiffusion · **Darkness** `0`–`15` · **PrintSpeed** = in/sec ×10
-(`0` = leave it to the printer) · **MediaTracking** `Gap`/`BlackMark`/`Continuous`/`PrinterDefault`.
+(`0` = leave it to the printer) · **MediaTracking** `Gap`/`BlackMark`/`Continuous`/`FixedPitch`/`PrinterDefault`
+· **GapLength** = mm ×10 (`20` = 2 mm). `lpadmin -o GapLength=` only keeps values the PPD lists
+(`15 20 25 30 40 50`); for any other value use `-o GapLength-default=17`, which CUPS then applies to
+every job on the queue.
diff --git a/ppd/tspl-label.ppd b/ppd/tspl-label.ppd
index 96dc05e..ede2d3d 100644
--- a/ppd/tspl-label.ppd
+++ b/ppd/tspl-label.ppd
@@ -104,13 +104,36 @@
*% BLINE (black-mark stock), GAP 0 (continuous roll), or send neither and use
*% the printer's stored setting. Sending GAP to continuous/black-mark stock
*% makes the firmware hunt for a gap that never comes and error out.
+*% FixedPitch is for die-cut stock whose gap the sensor cannot hold (short
+*% labels, 2 mm gaps): GAP 0 like Continuous, but SIZE carries the full
+*% label + GapLength pitch so the blind feed stays in register.
*DefaultMediaTracking: Gap
*MediaTracking Gap/Die-cut labels (gap sensor): ""
*MediaTracking BlackMark/Black-mark stock: ""
*MediaTracking Continuous/Continuous roll: ""
+*MediaTracking FixedPitch/Die-cut labels, sensor off (feed label + gap): ""
*MediaTracking PrinterDefault/Use printer setting: ""
*CloseUI: *MediaTracking
+*OpenUI *GapLength/Gap or black-mark length: PickOne
+*OrderDependency: 46 AnySetup *GapLength
+*% Physical length of the gap between die-cut labels, or the height of the
+*% black mark, in tenths of a millimetre (30 = 3 mm: the TSC factory default
+*% and what every TSPL driver sends). Goes out as the first argument of
+*% GAP / BLINE; the second (sensor offset) is always 0. Many small die-cut
+*% labels are cut with a 2 mm gap, TSC's stated sensor minimum; measure yours
+*% if labels lose register. On FixedPitch queues the value is added to SIZE
+*% instead, so the printer feeds label + gap per label with the sensor off.
+*% Ignored for Continuous and Use printer setting.
+*DefaultGapLength: 30
+*GapLength 15/1.5 mm: ""
+*GapLength 20/2 mm: ""
+*GapLength 25/2.5 mm: ""
+*GapLength 30/3 mm (default): ""
+*GapLength 40/4 mm: ""
+*GapLength 50/5 mm: ""
+*CloseUI: *GapLength
+
*OpenUI *Darkness/Darkness: PickOne
*OrderDependency: 50 AnySetup *Darkness
*DefaultDarkness: 8
diff --git a/src/rastertotspl.c b/src/rastertotspl.c
index b044978..57e3e6f 100644
--- a/src/rastertotspl.c
+++ b/src/rastertotspl.c
@@ -23,8 +23,13 @@
* CUPS options honoured (same as the vendor PPD, plus MediaTracking):
* Darkness (0..15) -> DENSITY
* PrintSpeed (10..60 = ips x10) -> SPEED (in/sec); 0 = omit (printer default)
- * MediaTracking Gap / BlackMark / Continuous / PrinterDefault
- * -> GAP 3 mm / BLINE 3 mm / GAP 0 / (omitted)
+ * MediaTracking Gap / BlackMark / Continuous / FixedPitch / PrinterDefault
+ * -> GAP mm / BLINE mm / GAP 0 / GAP 0 / (omitted)
+ * GapLength (tenths of mm, 30 = 3 mm; a bare 1..9 or "2.5" is read as mm)
+ * -> , the gap / black-mark length; default 3 mm.
+ * FixedPitch: nothing but SIZE stops the feed, so GapLength is
+ * added to SIZE's height and the printer feeds label + gap per
+ * label (die-cut stock whose gap the sensor cannot hold).
* Horizontal,Vertical (dots) -> REFERENCE
* PrintMode 0 None / 2 Diffusion / 3 Gathering / 4 ErrorDiffusion / 5 Default
* -> halftone used to flatten 8bpp grey into 1bpp dots.
@@ -75,6 +80,29 @@ static const int CLUSTER8[8][8] = {
* the PPD's marked default (which reflects the queue default set via
* `lpadmin -p QUEUE -o Name=Value`); otherwise the built-in fallback. This is
* what lets two queues sharing this filter have different defaults. */
+/* Raw option text: the job option first, else the queue PPD's marked choice
+ * (valid only until ppdClose). */
+static const char *opt_str(ppd_file_t *ppd, int num_options, cups_option_t *options,
+ const char *kw)
+{
+ const char *v = cupsGetOption(kw, num_options, options);
+ if (v) return v;
+ ppd_choice_t *c;
+ if (ppd && (c = ppdFindMarkedChoice(ppd, kw)) != NULL) return c->choice;
+ return NULL;
+}
+
+/* Tenths of mm -> "3" or "2.5". Whole millimetres keep the integer form the
+ * stream has always used (the default bytes stay identical); fractions are in
+ * the TSC spec ("GAP 7.62 mm,2.54 mm" is a manual example) and are built from
+ * ints so no locale can turn the point into a comma. */
+static const char *fmt_mm(char *buf, size_t n, int tenths)
+{
+ if (tenths % 10) snprintf(buf, n, "%d.%d", tenths / 10, tenths % 10);
+ else snprintf(buf, n, "%d", tenths / 10);
+ return buf;
+}
+
static int opt_int(ppd_file_t *ppd, int num_options, cups_option_t *options,
const char *kw, int dflt)
{
@@ -110,25 +138,61 @@ int main(int argc, char *argv[])
int href = opt_int(ppd, num_options, options, "Horizontal", 0);
int vref = opt_int(ppd, num_options, options, "Vertical", 0);
- /* MediaTracking -> the boundary command. GAP/BLINE select the sensor;
- * sending GAP to continuous or black-mark stock makes the firmware hunt
- * for a gap that never comes (feeds a label + margin, then errors), so an
- * unrecognized value must not silently fall through without a warning.
- * Resolved to a static string here because ppdClose frees the choice. */
- const char *track = "GAP 3 mm,0 mm\r\n"; /* Gap (die-cut) */
+ /* MediaTracking -> how the printer finds the next label. GAP/BLINE select
+ * the sensor; sending GAP to continuous or black-mark stock makes the
+ * firmware hunt for a gap that never comes (feeds a label + margin, then
+ * errors), so an unrecognized value must not silently fall through
+ * without a warning. FixedPitch is the escape hatch for die-cut stock
+ * whose gap the sensor cannot hold (short labels, gaps at the 2 mm sensor
+ * floor): GAP 0 like Continuous, but SIZE carries the full label + gap
+ * pitch so the blind feed stays in register.
+ * Resolved to an enum here because ppdClose frees the choice. */
+ enum { TRK_GAP, TRK_BLINE, TRK_CONTINUOUS, TRK_FIXEDPITCH, TRK_PRINTER } track = TRK_GAP;
{
- const char *v = cupsGetOption("MediaTracking", num_options, options);
- ppd_choice_t *c;
- if (!v && ppd && (c = ppdFindMarkedChoice(ppd, "MediaTracking")) != NULL)
- v = c->choice;
+ const char *v = opt_str(ppd, num_options, options, "MediaTracking");
if (v && *v) {
- if (!strcasecmp(v, "BlackMark")) track = "BLINE 3 mm,0 mm\r\n";
- else if (!strcasecmp(v, "Continuous")) track = "GAP 0 mm,0 mm\r\n";
- else if (!strcasecmp(v, "PrinterDefault")) track = ""; /* stored setting */
+ if (!strcasecmp(v, "BlackMark")) track = TRK_BLINE;
+ else if (!strcasecmp(v, "Continuous")) track = TRK_CONTINUOUS;
+ else if (!strcasecmp(v, "FixedPitch")) track = TRK_FIXEDPITCH;
+ else if (!strcasecmp(v, "PrinterDefault")) track = TRK_PRINTER; /* stored setting */
else if (strcasecmp(v, "Gap"))
fprintf(stderr, "WARNING: unknown MediaTracking '%s' — assuming "
- "Gap (die-cut); use Gap, BlackMark, Continuous or "
- "PrinterDefault\n", v);
+ "Gap (die-cut); use Gap, BlackMark, Continuous, "
+ "FixedPitch or PrinterDefault\n", v);
+ }
+ }
+
+ /* GapLength: the gap / black-mark length in tenths of mm (30 = 3 mm).
+ * 3 mm is the TSC factory default and what every other TSPL driver sends;
+ * small die-cut labels are often cut with 2 mm, TSC's sensor floor. As
+ * with PrintSpeed, a bare 1..9 (or anything with a decimal point) is read
+ * as whole mm so a hand-typed -o GapLength=2 does the intuitive thing.
+ * Bounds: the spec caps GAP/BLINE at 25.4 mm, and under 1 mm on a sensor
+ * mode would go out as GAP 0 -- which the firmware takes as "continuous",
+ * switches the sensor off, and remembers across jobs. */
+ int gap_tenths = 30;
+ {
+ const char *v = opt_str(ppd, num_options, options, "GapLength");
+ if (v && *v) {
+ char *end;
+ double n = strtod(v, &end);
+ if (end == v || n < 0) {
+ fprintf(stderr, "WARNING: GapLength '%s' is not a length — using 3 mm\n", v);
+ } else {
+ if (strchr(v, '.') || n < 10) n *= 10; /* millimetres -> tenths */
+ gap_tenths = (int)lround(n);
+ if (gap_tenths > 254) {
+ fprintf(stderr, "WARNING: GapLength %s is over the TSPL maximum of "
+ "25.4 mm — clamped\n", v);
+ gap_tenths = 254;
+ }
+ if (gap_tenths < 10 && (track == TRK_GAP || track == TRK_BLINE)) {
+ fprintf(stderr, "WARNING: GapLength %s is under 1 mm, which would switch "
+ "the %s sensor off (GAP 0 = continuous) — using 3 mm\n",
+ v, track == TRK_GAP ? "gap" : "black-mark");
+ gap_tenths = 30;
+ }
+ }
}
}
if (ppd) ppdClose(ppd);
@@ -257,8 +321,24 @@ int main(int argc, char *argv[])
* CRLF is the cheapest way back to command mode (pdf2tspl et al). */
int wmm = (int)lround((double)W * 25.4 / resx);
int hmm = (int)lround((double)H * 25.4 / resy);
- printf("\r\nSIZE %d mm,%d mm\r\n", wmm, hmm);
- fputs(track, stdout);
+ char sizeh[16], gapstr[16];
+ if (track == TRK_FIXEDPITCH) {
+ /* Nothing but SIZE stops the feed, so it must be the true pitch:
+ * label height plus the physical gap, summed in tenths and rounded
+ * once, because on a blind feed every rounding error accumulates
+ * label after label. A decimal SIZE is spec (and what the TSC and
+ * Munbyn vendor filters emit). */
+ fmt_mm(sizeh, sizeof sizeh, (int)lround((double)H * 254.0 / resy) + gap_tenths);
+ } else
+ snprintf(sizeh, sizeof sizeh, "%d", hmm);
+ printf("\r\nSIZE %d mm,%s mm\r\n", wmm, sizeh);
+ switch (track) {
+ case TRK_GAP: printf("GAP %s mm,0 mm\r\n", fmt_mm(gapstr, sizeof gapstr, gap_tenths)); break;
+ case TRK_BLINE: printf("BLINE %s mm,0 mm\r\n", fmt_mm(gapstr, sizeof gapstr, gap_tenths)); break;
+ case TRK_CONTINUOUS:
+ case TRK_FIXEDPITCH: fputs("GAP 0 mm,0 mm\r\n", stdout); break;
+ case TRK_PRINTER: break; /* stored setting */
+ }
printf("DENSITY %d\r\n", darkness);
if (speed_ips >= 1)
printf("SPEED %d\r\n", speed_ips);
@@ -270,8 +350,12 @@ int main(int argc, char *argv[])
fflush(stdout);
free(bm);
- fprintf(stderr, "INFO: TSPL page %d: %ux%u dots (%dx%dmm) mode=%d density=%d speed=%d copies=%u\n",
- page, W, H, wmm, hmm, printmode, darkness, speed_ips, copies);
+ static const char *const trkname[] =
+ { "Gap", "BlackMark", "Continuous", "FixedPitch", "PrinterDefault" };
+ fprintf(stderr, "INFO: TSPL page %d: %ux%u dots (SIZE %dx%smm) tracking=%s gap=%smm "
+ "mode=%d density=%d speed=%d copies=%u\n",
+ page, W, H, wmm, sizeh, trkname[track], fmt_mm(gapstr, sizeof gapstr, gap_tenths),
+ printmode, darkness, speed_ips, copies);
}
cupsRasterClose(ras);
diff --git a/tests/smoke.sh b/tests/smoke.sh
index 789d8d4..87c72e4 100644
--- a/tests/smoke.sh
+++ b/tests/smoke.sh
@@ -16,7 +16,7 @@ fail() { echo "FAIL: $*" 1>&2; exit 1; }
make -s src/rastertotspl tests/mkras
OUT="${TMPDIR:-/tmp}/tspl-smoke.$$"
-trap 'rm -f "$OUT" "$OUT.ras" "$OUT.txt" "$OUT.hex"' EXIT
+trap 'rm -f "$OUT" "$OUT.ras" "$OUT.txt" "$OUT.hex" "$OUT.err"' EXIT
tests/mkras > "$OUT.ras"
src/rastertotspl 1 tester smoke 3 '' < "$OUT.ras" > "$OUT" 2>/dev/null
@@ -51,16 +51,52 @@ grep -q '^PRINT 1,4$' "$OUT.txt" || fail "page 2 should print 4 device copies"
grep -q '^PRINT 1,3$' "$OUT.txt" && fail "argv[4] copies leaked into PRINT"
# --- option handling: BlackMark -> BLINE (no GAP), PrintSpeed=0 -> no SPEED ---
-src/rastertotspl 1 tester smoke 1 'MediaTracking=BlackMark PrintSpeed=0' \
- < "$OUT.ras" 2>/dev/null | tr -d '\r' > "$OUT.txt"
-grep -q '^BLINE 3 mm,0 mm' "$OUT.txt" || fail "BlackMark should emit BLINE"
+opt() { src/rastertotspl 1 tester smoke 1 "$1" < "$OUT.ras" 2>"$OUT.err" | tr -d '\r' > "$OUT.txt"; }
+opt 'MediaTracking=BlackMark PrintSpeed=0'
+grep -qx 'BLINE 3 mm,0 mm' "$OUT.txt" || fail "BlackMark should emit BLINE 3 mm"
grep -q '^GAP' "$OUT.txt" && fail "BlackMark must not also emit GAP"
grep -q '^SPEED' "$OUT.txt" && fail "PrintSpeed=0 must omit SPEED"
-# --- continuous media -> GAP 0; out-of-range speed clamps to 6 ips ---
-src/rastertotspl 1 tester smoke 1 'MediaTracking=Continuous PrintSpeed=9' \
- < "$OUT.ras" 2>/dev/null | tr -d '\r' > "$OUT.txt"
-grep -q '^GAP 0 mm,0 mm' "$OUT.txt" || fail "Continuous should emit GAP 0"
-grep -q '^SPEED 6$' "$OUT.txt" || fail "PrintSpeed=9 should clamp to SPEED 6"
+# --- continuous roll -> GAP 0 and SIZE stays the page height (nothing to add:
+# the page IS the feed length); out-of-range speed clamps to 6 ips ---
+opt 'MediaTracking=Continuous PrintSpeed=9'
+grep -qx 'GAP 0 mm,0 mm' "$OUT.txt" || fail "Continuous should emit GAP 0"
+grep -qx 'SIZE 1 mm,1 mm' "$OUT.txt" || fail "Continuous must not pad SIZE"
+grep -qx 'SPEED 6' "$OUT.txt" || fail "PrintSpeed=9 should clamp to SPEED 6"
+
+# --- GapLength (tenths of mm) on the sensor modes: whole millimetres keep the
+# integer form, fractions are spec ("GAP 7.62 mm,2.54 mm" is a manual
+# example); a bare 1..9 or a decimal is read as mm, like PrintSpeed's ips ---
+opt 'GapLength=20'; grep -qx 'GAP 2 mm,0 mm' "$OUT.txt" || fail "GapLength=20 -> GAP 2 mm"
+opt 'GapLength=2'; grep -qx 'GAP 2 mm,0 mm' "$OUT.txt" || fail "bare GapLength=2 -> GAP 2 mm"
+opt 'GapLength=2.5'; grep -qx 'GAP 2.5 mm,0 mm' "$OUT.txt" || fail "GapLength=2.5 -> GAP 2.5 mm"
+opt 'MediaTracking=BlackMark GapLength=15'
+grep -qx 'BLINE 1.5 mm,0 mm' "$OUT.txt" || fail "GapLength=15 -> BLINE 1.5 mm"
+opt 'MediaTracking=PrinterDefault GapLength=20'
+grep -qE '^(GAP|BLINE)' "$OUT.txt" && fail "PrinterDefault must send no boundary command"
+
+# --- GapLength guards: under 1 mm or unparsable on a sensor mode would go out
+# as GAP 0 = continuous, switching the sensor off and persisting in the
+# printer -> warn and fall back to 3 mm; the spec caps GAP at 25.4 mm ---
+for bad in 'GapLength=0' 'GapLength=abc' 'GapLength=-5' 'MediaTracking=BlackMark GapLength=0'; do
+ opt "$bad"
+ grep -qE '^(GAP|BLINE) 3 mm,0 mm$' "$OUT.txt" || fail "$bad should fall back to 3 mm"
+ grep -q '^WARNING' "$OUT.err" || fail "$bad should warn"
+done
+opt 'GapLength=999'
+grep -qx 'GAP 25.4 mm,0 mm' "$OUT.txt" || fail "GapLength=999 should clamp to 25.4 mm"
+grep -q '^WARNING' "$OUT.err" || fail "GapLength=999 should warn"
+
+# --- FixedPitch: GAP 0 like Continuous, but SIZE is label + gap summed in
+# tenths and rounded once (8 dots @300 dpi = 0.68 mm -> 0.7; + 3 mm = 3.7;
+# + 2.5 mm = 3.2; + 0 = 0.7, and 0 is valid here) ---
+opt 'MediaTracking=FixedPitch'
+grep -qx 'GAP 0 mm,0 mm' "$OUT.txt" || fail "FixedPitch should emit GAP 0"
+grep -qx 'SIZE 1 mm,3.7 mm' "$OUT.txt" || fail "FixedPitch should add the 3 mm default gap to SIZE"
+opt 'MediaTracking=FixedPitch GapLength=25'
+grep -qx 'SIZE 1 mm,3.2 mm' "$OUT.txt" || fail "FixedPitch GapLength=25 -> SIZE 3.2 mm"
+opt 'MediaTracking=FixedPitch GapLength=0'
+grep -qx 'SIZE 1 mm,0.7 mm' "$OUT.txt" || fail "FixedPitch GapLength=0 -> bare label height"
+grep -q '^WARNING' "$OUT.err" && fail "GapLength=0 is valid on FixedPitch"
echo "smoke test OK"